Skip to content

Commit

Permalink
fix: convert nulls to undefined for non-nullable Prisma fields (#695)
Browse files Browse the repository at this point in the history
  • Loading branch information
Weakky authored Jun 11, 2020
1 parent cdfcda2 commit 2cabc65
Show file tree
Hide file tree
Showing 7 changed files with 293 additions and 6 deletions.
6 changes: 6 additions & 0 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import {
FieldNamingStrategy,
OperationName,
} from './naming-strategies'
import { transformNullsToUndefined } from './null'
import { proxifyModelFunction, proxifyPublishers } from './proxifier'
import { Publisher } from './publisher'
import * as Typegen from './typegen'
import {
assertPhotonInContext,
GlobalComputedInputs,
Index,
indexBy,
isEmptyObject,
LocalComputedInputs,
lowerFirst,
Expand Down Expand Up @@ -309,6 +311,7 @@ export class SchemaBuilder {
field: mappedField.field,
givenConfig: givenConfig ? givenConfig : {},
})
const schemaArgsIndex = indexBy(mappedField.field.args, 'name')

const originalResolve: GraphQLFieldResolver<any, any, any> = (
_root,
Expand All @@ -322,6 +325,7 @@ export class SchemaBuilder {
(!isEmptyObject(publisherConfig.locallyComputedInputs) ||
!isEmptyObject(this.globallyComputedInputs))
) {
args = transformNullsToUndefined(args, schemaArgsIndex, this.dmmf)
args = addComputedInputs({
inputType,
dmmf: this.dmmf,
Expand Down Expand Up @@ -523,6 +527,7 @@ export class SchemaBuilder {
typeName,
this.dmmf,
)
const schemaArgsIndex = indexBy(field.args, 'name')

const originalResolve: GraphQLFieldResolver<any, any, any> | undefined =
field.outputType.kind === 'object'
Expand All @@ -544,6 +549,7 @@ export class SchemaBuilder {

const photon = this.getPrismaClient(ctx)

args = transformNullsToUndefined(args, schemaArgsIndex, this.dmmf)
args = this.paginationStrategy.resolve(args)

return photon[lowerFirst(mapping.model)]
Expand Down
43 changes: 38 additions & 5 deletions src/dmmf/DmmfDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class DmmfDocument implements DmmfTypes.Document {
public mappingsIndex: Index<DmmfTypes.Mapping>
public enumsIndex: Index<DmmfTypes.Enum>
public modelsIndex: Index<DmmfTypes.Model>
public inputTypesIndexWithFields: InputTypeIndexWithField

constructor({ datamodel, schema, mappings }: DmmfTypes.Document) {
// ExternalDMMF
Expand All @@ -20,11 +21,12 @@ export class DmmfDocument implements DmmfTypes.Document {
this.mappings = mappings

// Indices
this.modelsIndex = indexBy('name', datamodel.models)
this.enumsIndex = indexBy('name', schema.enums)
this.inputTypesIndex = indexBy('name', schema.inputTypes)
this.outputTypesIndex = indexBy('name', schema.outputTypes)
this.mappingsIndex = indexBy('model', mappings)
this.modelsIndex = indexBy(datamodel.models, 'name')
this.enumsIndex = indexBy(schema.enums, 'name')
this.inputTypesIndex = indexBy(schema.inputTypes, 'name')
this.outputTypesIndex = indexBy(schema.outputTypes, 'name')
this.mappingsIndex = indexBy(mappings, 'model')
this.inputTypesIndexWithFields = indexInputTypeWithFields(schema.inputTypes)

// Entrypoints
this.queryObject = this.getOutputType('Query')
Expand All @@ -41,6 +43,16 @@ export class DmmfDocument implements DmmfTypes.Document {
return inputType
}

getInputTypeWithIndexedFields(inputTypeName: string) {
const inputType = this.inputTypesIndexWithFields[inputTypeName]

if (!inputType) {
throw new Error('Could not find input type name: ' + inputTypeName)
}

return inputType
}

getOutputType(outputTypeName: string): OutputType {
const outputType = this.outputTypesIndex[outputTypeName]

Expand Down Expand Up @@ -135,3 +147,24 @@ export class OutputType {
return field
}
}

type InputTypeIndexWithField = Index<
Omit<DmmfTypes.InputType, 'fields'> & {
fields: Index<DmmfTypes.SchemaArg>
}
>

function indexInputTypeWithFields(inputTypes: DmmfTypes.InputType[]) {
const indexedInputTypes: InputTypeIndexWithField = {}

for (const inputType of inputTypes) {
const indexedFields = indexBy(inputType.fields, 'name')

indexedInputTypes[inputType.name] = {
...inputType,
fields: indexedFields,
}
}

return indexedInputTypes
}
1 change: 1 addition & 0 deletions src/dmmf/DmmfTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export declare namespace DmmfTypes {
inputType: {
isRequired: boolean
isList: boolean
isNullable: boolean
type: ArgType
kind: FieldKind
}
Expand Down
46 changes: 46 additions & 0 deletions src/null.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DmmfDocument, DmmfTypes } from './dmmf'

/**
* Take the incoming GraphQL args of a resolver and replaces all `null` values
* that maps to a non-nullable field in the Prisma Schema, by `undefined` values.
*
* In Prisma, a `null` value has a specific meaning for the underlying database.
* Therefore, `undefined` is used instead to express the optionality of a field.
*
* In GraphQL however, no difference is made between `null` and `undefined`.
* This is the reason why we need to convert all `null` values that were assigned to `non-nullable` fields to `undefined`.
*/
export function transformNullsToUndefined(
graphqlArgs: Record<string, any>,
prismaArgs: Record<string, DmmfTypes.SchemaArg>,
dmmf: DmmfDocument,
) {
const keys = Object.keys(graphqlArgs)
for (const key of keys) {
const val = graphqlArgs[key]
const prismaArg = prismaArgs[key]

if (!prismaArg) {
throw new Error(`Could not find schema arg with name: ${key}`)
}

const shouldConvertNullToUndefined =
val === null && prismaArg.inputType.isNullable === false

if (shouldConvertNullToUndefined) {
graphqlArgs[key] = undefined
} else if (isObject(val)) {
const nestedPrismaArgs = dmmf.getInputTypeWithIndexedFields(
prismaArg.inputType.type,
).fields

graphqlArgs[key] = transformNullsToUndefined(graphqlArgs[key], nestedPrismaArgs, dmmf)
}
}

return graphqlArgs
}

function isObject(obj: any): boolean {
return obj && typeof obj === 'object'
}
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export const hardWriteFileSync = (filePath: string, data: string): void => {
* TODO
*/
export const indexBy = <X extends Record<string, any>>(
indexer: ((x: X) => string) | keyof X,
xs: X[],
indexer: ((x: X) => string) | keyof X,
): Index<X> => {
const seed: Index<X> = {}
if (typeof indexer === 'function') {
Expand Down
52 changes: 52 additions & 0 deletions tests/runtime/__snapshots__/null.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`create: converts nulls to undefined when fields are not nullable 1`] = `
Object {
"data": Object {
"id": undefined,
"posts": Object {
"connect": undefined,
"create": Object {
"id": "titi",
},
},
},
}
`;

exports[`findMany: converts nulls to undefined when fields are not nullable 1`] = `
Object {
"after": undefined,
"before": undefined,
"first": 1,
"orderBy": Object {
"birthDate": null,
"email": null,
"id": "asc",
},
"where": Object {
"AND": undefined,
"NOT": Object {
"AND": Object {
"birthDate": undefined,
},
"posts": null,
},
},
}
`;

exports[`model filtering: converts nulls to undefined when fields are not nullable 1`] = `
Object {
"where": Object {
"authors": Object {
"every": Object {
"birthDate": undefined,
"email": null,
"posts": null,
},
},
"id": undefined,
},
}
`;
149 changes: 149 additions & 0 deletions tests/runtime/null.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { DmmfTypes, DmmfDocument } from '../../src/dmmf'
import { getCrudMappedFields } from '../../src/mapping'
import { OperationName } from '../../src/naming-strategies'
import { transformNullsToUndefined } from '../../src/null'
import { indexBy } from '../../src/utils'
import { getDmmf } from '../__utils'

const operationToRoot: Record<OperationName, 'Query' | 'Mutation'> = {
findOne: 'Query',
findMany: 'Query',
create: 'Mutation',
delete: 'Mutation',
deleteMany: 'Mutation',
update: 'Mutation',
updateMany: 'Mutation',
upsert: 'Mutation'
}

async function getSchemaArgsForCrud(
datamodel: string,
model: string,
operation: OperationName,
): Promise<{
schemaArgs: Record<string, DmmfTypes.SchemaArg>
dmmf: DmmfDocument
}> {
const dmmf = await getDmmf(datamodel)
const mappedField = getCrudMappedFields(operationToRoot[operation], dmmf).find(
x => x.operation === operation && x.model === model,
)

if (!mappedField) {
throw new Error(
`Could not find mapped fields for model ${model} and operation ${operation}`,
)
}

return {
schemaArgs: indexBy(mappedField.field.args, 'name'),
dmmf,
}
}

test('findMany: converts nulls to undefined when fields are not nullable', async () => {
const datamodel = `
model User {
id String @default(cuid()) @id
email String? @unique
birthDate DateTime
posts Post[]
}
model Post {
id String @default(cuid()) @id
authors User[] @relation(references: [id])
}
`
const { dmmf, schemaArgs } = await getSchemaArgsForCrud(datamodel, 'User', 'findMany')
const incomingArgs = {
before: null, // not nullable
after: null, // not nullable
first: 1,
orderBy: {
email: null, // nullable
birthDate: null, // nullable
id: 'asc',
},
where: {
AND: null, // not nullable
NOT: {
AND: {
birthDate: null, // not nullable
},
posts: null,
},
},
}

const result = transformNullsToUndefined(incomingArgs, schemaArgs, dmmf)

expect(result).toMatchSnapshot()
})

test('create: converts nulls to undefined when fields are not nullable', async () => {
const datamodel = `
model User {
id String @default(cuid()) @id
email String? @unique
birthDate DateTime
posts Post[]
}
model Post {
id String @default(cuid()) @id
authors User[] @relation(references: [id])
}
`
const { dmmf, schemaArgs } = await getSchemaArgsForCrud(datamodel, 'User', 'create')
const incomingArgs = {
data: {
id: null, // not nullable
posts: {
connect: null, // not nullable
create: {
id: 'titi',
},
},
},
}

const result = transformNullsToUndefined(incomingArgs, schemaArgs, dmmf)

expect(result).toMatchSnapshot()
})

test('model filtering: converts nulls to undefined when fields are not nullable', async () => {
const datamodel = `
model User {
id String @default(cuid()) @id
email String? @unique
birthDate DateTime
posts Post[]
}
model Post {
id String @default(cuid()) @id
authors User[] @relation(references: [id])
}
`
const dmmf = await getDmmf(datamodel)
const schemaArgs = dmmf.getOutputType('User').fields.find(f => f.name === 'posts')?.args!
const indexedSchemaArgs = indexBy(schemaArgs, 'name')
const incomingArgs = {
where: {
id: null, // not nullable
authors: {
every: {
email: null,
birthDate: null, // not nullable
posts: null,
},
},
},
}

const result = transformNullsToUndefined(incomingArgs, indexedSchemaArgs, dmmf)

expect(result).toMatchSnapshot()
})

0 comments on commit 2cabc65

Please sign in to comment.