Skip to content

Commit

Permalink
feat(ts): Improves return types of QueryResolvers, MutationResolvers …
Browse files Browse the repository at this point in the history
…and <Model>Resolvers (#6228)

Co-authored-by: Tobbe Lundberg <tobbe@tlundberg.com>
Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>
  • Loading branch information
3 people authored Aug 30, 2022
1 parent b0e0b7d commit 259fd77
Show file tree
Hide file tree
Showing 15 changed files with 347 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const standard = defineScenario<Prisma.PostCreateArgs>({
body: 'String',
author: {
create: {
email: 'String1880855',
email: 'String5205455',
hashedPassword: 'String',
fullName: 'String',
salt: 'String',
Expand All @@ -24,7 +24,7 @@ export const standard = defineScenario<Prisma.PostCreateArgs>({
body: 'String',
author: {
create: {
email: 'String3103243',
email: 'String4790139',
hashedPassword: 'String',
fullName: 'String',
salt: 'String',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ export const standard = defineScenario<Prisma.UserCreateArgs>({
user: {
one: {
data: {
email: 'String8646974',
email: 'String2936724',
hashedPassword: 'String',
fullName: 'String',
salt: 'String',
},
},
two: {
data: {
email: 'String1817344',
email: 'String512848',
hashedPassword: 'String',
fullName: 'String',
salt: 'String',
Expand Down
2 changes: 1 addition & 1 deletion __fixtures__/test-project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"prisma": {
"seed": "yarn rw exec seed"
},
"packageManager": "yarn@3.2.2",
"packageManager": "yarn@3.2.3",
"resolutions": {
"jest": "29.0.0"
}
Expand Down
91 changes: 84 additions & 7 deletions docs/docs/typescript/strict-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,26 +75,103 @@ One of the challenges in the GraphQL-Prisma world is the difference in the way t
- but For Prisma, `null` is a value, and `undefined` means "do nothing"

This is covered in detail in [Prisma's docs](https://www.prisma.io/docs/concepts/components/prisma-client/null-and-undefined), which we strongly recommend reading.
But the gist of it is that, for Prisma's create and update operations, you have to make sure `null`s are converted to `undefined` from your GraphQL mutation inputs.
One way to do this is to use the [dnull](https://www.npmjs.com/package/dnull) package:
But the gist of it is that, for Prisma's create and update operations, you may have to make sure `null`s are converted to `undefined` from your GraphQL mutation inputs. You'll have to think carefully about the behaviour you want - if the client is expected to send null, and you expect those fields to be set to null, you can make the field nullable in your Prisma schema. Sending a null will mean removing that value, sending undefined will mean that the field won't be updated.

```
yarn workspace api add dnull
```
For most cases however, you probably want to convert nulls to undefined - one way to do this is to use the `removeNulls` utility function from `@redwoodjs/api`:

```ts title=api/src/services/users.ts
// highlight-next-line
import { dnull } from "dnull"
import { removeNulls } from "@redwoodjs/api"

export const updateUser: MutationResolvers["updateUser"] = ({ id, input }) => {
return db.user.update({
// highlight-next-line
data: dnull(input),
data: removeNulls(input),
where: { id },
})
}
```

### Relation resolvers in services

Let's say you have a `Post` model in your `schema.prisma` that has an `author` field which is a relation to the `Author` model. It's a required field.
This is what the `Post` model's SDL would probably look like:

```graphql post.sdl.ts
export const schema = gql`
type Post {
id: Int!
title: String!
// highlight-next-line
author: Author! # 👈 This is a relation; the `!` makes it a required field
authorId: Int!
# ...
}
```

When you generate SDLs or Services, the resolver for `author` is generated at the bottom of `post.service.ts` on the `Post` object.
Because `Post.author` can't be null (we said it's required in the Prisma schema)—and because `findUnique` always returns a nullable value—in strict mode, you'll have to tweak this resolver:

```ts Post.service.ts
// Option 1: Override the type
// The typecasting here is OK. `gqlArgs.root` is the post that was _already found_
// by the `post` function in your Services, so `findUnique` will always find it!
export const Post: Partial<PostResolvers> = {
author: (_obj, gqlArgs) =>
db.post.findUnique({ where: { id: gqlArgs?.root?.id } }).author() as Promise<Author>, // 👈
}

// Option 2: Check for null
export const Post: Partial<PostResolvers> = {
author: async (_obj, gqlArgs) => {
// Here, `findUnique` can return `null`, so we have to handle it:
const maybeAuthor = await db.post
.findUnique({ where: { id: gqlArgs?.root?.id } })
.author()

// highlight-start
if (!maybeAuthor) {
throw new Error('Could not resolve author')
}
// highlight-end

return maybeAuthor
},
}
```


:::tip An optimization tip

If the relation truly is required, it may make more sense to include `author` in your `post` Service's Prisma query and modify the `Post.author` resolver accordingly:

```ts
export const post: QueryResolvers['post'] = ({ id }) => {
return db.post.findUnique({
// highlight-start
include: {
author: true,
},
// highlight-end
where: { id },
})
}

export const Post: Partial<PostResolvers> = {
author: async (_obj, gqlArgs) => {
// highlight-start
if (gqlArgs?.root.author) {
return gqlArgs.root.author
}
// highlight-end

const maybeAuthor = await db.post.findUnique(// ...
```
This will also help Prisma make a more optimized query to the database, since every time a field on `Post` is requested, the post's author is too! The tradeoff here is that any query to `Post` (even if the author isn't requested) will mean an unnecessary database query to include the author.
:::
### Roles checks for CurrentUser in `src/lib/auth`
When you setup auth, Redwood includes some template code for handling roles with the `hasRole` function.
Expand Down
37 changes: 37 additions & 0 deletions packages/api/src/__tests__/transforms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { removeNulls } from '../transforms'

describe('removeNulls utility', () => {
it('Changes nulls to undefined', () => {
const input = {
a: null,
b: 'b',
c: {
d: null, // nested null
e: 3,
f: {
g: null, // deeply nested null
h: [null, null], // array of nulls is also transformed
i: [1, 2, null, 4],
},
},
myDate: new Date('2020-01-01'),
}

const result = removeNulls(input)

expect(result).toEqual({
a: undefined,
b: 'b',
c: {
d: undefined,
e: 3,
f: {
g: undefined,
h: [undefined, undefined],
i: [1, 2, undefined, 4],
},
},
myDate: new Date('2020-01-01'),
})
})
})
2 changes: 1 addition & 1 deletion packages/api/src/functions/dbAuth/DbAuthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ type Params = {
* // key being used in dbAccessor in src/functions/auth.ts 👇
* const getCurrentUser = async (session: DbAuthSession<User['id']>)
*/
export interface DbAuthSession<TIdType = unknown> {
export interface DbAuthSession<TIdType = any> {
id: TIdType
}

Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export { dbAuthSession, hashPassword } from './functions/dbAuth/shared'
export * from './validations/validations'
export * from './validations/errors'

export * from './types'

export * from './transforms'
export * from './cors'

Expand Down
23 changes: 23 additions & 0 deletions packages/api/src/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,26 @@ export function normalizeRequest(event: APIGatewayProxyEvent): Request {
body,
}
}

// Internal note: Equivalent to dnull package on npm, which seems to have import issues in latest versions

/**
* Useful for removing nulls from an object, such as an input from a GraphQL mutation used directly in a Prisma query
* @param input - Object to remove nulls from
* See {@link https://www.prisma.io/docs/concepts/components/prisma-client/null-and-undefined Prisma docs: null vs undefined}
*/
export const removeNulls = (input: Record<number | symbol | string, any>) => {
for (const key in input) {
if (input[key] === null) {
input[key] = undefined
} else if (
typeof input[key] === 'object' &&
!(input[key] instanceof Date) // dates are objects too
) {
// Note arrays are also typeof object!
input[key] = removeNulls(input[key])
}
}

return input
}
69 changes: 69 additions & 0 deletions packages/api/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Houses utility types commonly used on the api side
*/

import { O, A } from 'ts-toolbelt'

/**
* ---- Prisma SDL Type Merge ----
* SDL is source of truth for KEYS
* Prisma types is source of truth for VALUES (unless SDL-only field)
*/

type AnyObject = Record<string | symbol | number, unknown>
// Pick out unique keys on the SDL type
type SdlOnlyFields<TPrisma, TSdl> = Omit<TSdl, keyof TPrisma>

// Object with all the optional keys, so that we can make them nullable
type PrismaTypeWithOptionalKeysFromSdl<
TPrisma extends AnyObject,
TSdl extends AnyObject
> = Pick<TPrisma, O.OptionalKeys<TSdl>>

// Make the optional values nullable
type PrismaTypeWithOptionalKeysAndNullableValues<
TPrisma extends AnyObject,
TSdl extends AnyObject
> = {
[k in keyof PrismaTypeWithOptionalKeysFromSdl<TPrisma, TSdl>]?:
| PrismaTypeWithOptionalKeysFromSdl<TPrisma, TSdl>[k]
| null // Note: if we ever change the type of Maybe in codegen, it might be worth changing this to Maybe<T>
}

// Object with all the required keys
type PrismaTypeWithRequiredKeysFromSdl<
TPrisma extends AnyObject,
TSdl extends AnyObject
> = Pick<TPrisma, O.RequiredKeys<TSdl>>

// To replace the unknowns with types from Sdl on SDL-only fields
type OptionalsAndSdlOnly<
TPrisma extends AnyObject,
TSdl extends AnyObject
> = PrismaTypeWithOptionalKeysAndNullableValues<TPrisma, TSdl> &
SdlOnlyFields<TPrisma, TSdl>

export type MakeRelationsOptional<T, TAllMappedModels> = {
//object with optional relation keys
[key in keyof T as T[key] extends TAllMappedModels
? key
: never]?: MakeRelationsOptional<T[key], TAllMappedModels>
} & {
// object without the relation keys
[key in keyof T as T[key] extends TAllMappedModels ? never : key]: T[key]
}

// ⚡ All together now
// Note: don't use O.Merge here, because it results in unknowns
export type MergePrismaWithSdlTypes<
TPrisma extends AnyObject,
TSdl extends AnyObject,
TAllMappedModels
> = A.Compute<
OptionalsAndSdlOnly<TPrisma, MakeRelationsOptional<TSdl, TAllMappedModels>> &
PrismaTypeWithRequiredKeysFromSdl<
TPrisma,
MakeRelationsOptional<TSdl, TAllMappedModels>
>
>
// ---- Prisma SDL Type Merge ----
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

exports[`Generate gql typedefs api 1`] = `
"import { Prisma } from "@prisma/client"
import { MergePrismaWithSdlTypes, MakeRelationsOptional } from '@redwoodjs/api'
import { PrismaModelOne as PrismaPrismaModelOne, PrismaModelTwo as PrismaPrismaModelTwo, Post as PrismaPost, Todo as PrismaTodo } from '@prisma/client'
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
import { PrismaModelOne as PrismaPrismaModelOne, PrismaModelTwo as PrismaPrismaModelTwo, Post as PrismaPost, Todo as PrismaTodo } from '@prisma/client';
import { RedwoodGraphQLContext } from '@redwoodjs/graphql-server/dist/functions/types';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
Expand All @@ -23,11 +24,11 @@ export type Scalars = {
Int: number;
Float: number;
BigInt: number;
Date: string;
DateTime: string;
Date: Date | string;
DateTime: Date | string;
JSON: Prisma.JsonValue;
JSONObject: Prisma.JsonObject;
Time: string;
Time: Date | string;
};
export type Mutation = {
Expand Down Expand Up @@ -86,6 +87,8 @@ export type Todo = {
status: Scalars['String'];
};
type MaybeOrArrayOfMaybe<T> = T | Maybe<T> | Maybe<T>[];
type AllMappedModels = MaybeOrArrayOfMaybe<Todo>
export type ResolverTypeWrapper<T> = Promise<T> | T;
Expand Down Expand Up @@ -156,7 +159,7 @@ export type ResolversTypes = {
Redwood: ResolverTypeWrapper<Redwood>;
String: ResolverTypeWrapper<Scalars['String']>;
Time: ResolverTypeWrapper<Scalars['Time']>;
Todo: ResolverTypeWrapper<PrismaTodo>;
Todo: ResolverTypeWrapper<MergePrismaWithSdlTypes<PrismaTodo, MakeRelationsOptional<Todo, AllMappedModels>, AllMappedModels>>;
};
/** Mapping between all available schema types and the resolvers parents */
Expand All @@ -173,7 +176,7 @@ export type ResolversParentTypes = {
Redwood: Redwood;
String: Scalars['String'];
Time: Scalars['Time'];
Todo: PrismaTodo;
Todo: MergePrismaWithSdlTypes<PrismaTodo, MakeRelationsOptional<Todo, AllMappedModels>, AllMappedModels>;
};
export type requireAuthDirectiveArgs = {
Expand Down
Loading

0 comments on commit 259fd77

Please sign in to comment.