From 02149a331c888fdc5a25f04c94218186c5bb91c3 Mon Sep 17 00:00:00 2001 From: Sergey Tatarintsev Date: Thu, 4 Apr 2024 17:42:34 +0200 Subject: [PATCH] feat(client): Add `exclude` option to the queries Counterpart to prisma/prisma-engines#4807 Can be used standalone or combined with `include`, allows to exclude the fields that normally would be included by default. Available in all methods that return actual database records. It is not useable together with `select` and attempt to do so would cause type check and validation error. When using together with result extensions, excluded dependency of a computed field will be queried from a DB, but will not be returned to the end user, unless computed field is exlucded as well (see "exclude with extensions" tests in this PR). This behaviour is equivalent to what we do if depenency of a computed field is not mentioned in explicit `select`. TODO: - [ ] preview feature - [ ] validation of non-existing fields in exclude Close prisma/team-orm#1080 Close #5042 --- .../client/src/generation/TSClient/Args.ts | 210 +++++----- .../client/src/generation/TSClient/Count.ts | 24 +- .../client/src/generation/TSClient/Input.ts | 6 +- .../client/src/generation/TSClient/Model.ts | 59 ++- ...lectInclude.ts => SelectIncludeExclude.ts} | 33 +- .../client/src/generation/TSClient/common.ts | 9 +- .../client/src/generation/TSClient/index.ts | 1 - .../src/generation/ts-builders/Export.ts | 6 +- .../generation/ts-builders/FunctionType.ts | 1 + .../generation/ts-builders/KeyofType.test.ts | 32 ++ .../src/generation/ts-builders/KeyofType.ts | 21 + .../src/generation/ts-builders/TypeBuilder.ts | 3 + .../generation/ts-builders/TypeDeclaration.ts | 7 +- .../src/generation/ts-builders/UnionType.ts | 1 + .../src/generation/ts-builders/index.ts | 1 + packages/client/src/generation/utils.ts | 4 + .../applyValidationError.test.ts | 160 +++++++- .../errorRendering/applyValidationError.ts | 51 ++- .../extensions/applyAllResultExtensions.ts | 1 + .../extensions/applyResultExtensions.test.ts | 91 +++++ .../core/extensions/applyResultExtensions.ts | 15 +- ...esultUtils.spec.ts => resultUtils.test.ts} | 28 +- .../runtime/core/extensions/resultUtils.ts | 23 +- .../jsonProtocol/serializeJsonQuery.test.ts | 142 +++++++ .../core/jsonProtocol/serializeJsonQuery.ts | 62 ++- .../src/runtime/core/types/ValidationError.ts | 10 +- .../runtime/core/types/exported/Extensions.ts | 3 + .../src/runtime/core/types/exported/JsApi.ts | 3 + .../src/runtime/core/types/exported/Result.ts | 13 +- .../tests/functional/exclude/_matrix.ts | 13 + .../functional/exclude/prisma/_schema.ts | 30 ++ .../client/tests/functional/exclude/test.ts | 360 ++++++++++++++++++ 32 files changed, 1232 insertions(+), 191 deletions(-) rename packages/client/src/generation/TSClient/{SelectInclude.ts => SelectIncludeExclude.ts} (70%) create mode 100644 packages/client/src/generation/ts-builders/KeyofType.test.ts create mode 100644 packages/client/src/generation/ts-builders/KeyofType.ts rename packages/client/src/runtime/core/extensions/{resultUtils.spec.ts => resultUtils.test.ts} (85%) create mode 100644 packages/client/tests/functional/exclude/_matrix.ts create mode 100644 packages/client/tests/functional/exclude/prisma/_schema.ts create mode 100644 packages/client/tests/functional/exclude/test.ts diff --git a/packages/client/src/generation/TSClient/Args.ts b/packages/client/src/generation/TSClient/Args.ts index 9ca760ed6233..f7a5538dfc6a 100644 --- a/packages/client/src/generation/TSClient/Args.ts +++ b/packages/client/src/generation/TSClient/Args.ts @@ -1,144 +1,118 @@ -import indent from 'indent-string' - import { DMMF } from '../dmmf-types' -import { getIncludeName, getLegacyModelArgName, getModelArgName, getSelectName } from '../utils' -import { TAB_SIZE } from './constants' -import type { Generatable } from './Generatable' +import * as ts from '../ts-builders' +import { + extArgsParam, + getExcludeName, + getIncludeName, + getLegacyModelArgName, + getModelArgName, + getSelectName, +} from '../utils' import { GenerateContext } from './GenerateContext' import { getArgFieldJSDoc } from './helpers' -import { InputField } from './Input' +import { buildInputField } from './Input' + +export class ArgsTypeBuilder { + private moduleExport: ts.Export> + + private hasDefaultName = true -export class ArgsType implements Generatable { - private generatedName: string | null = null - private comment: string | null = null constructor( - protected readonly args: readonly DMMF.SchemaArg[], - protected readonly type: DMMF.OutputType, - protected readonly context: GenerateContext, - protected readonly action?: DMMF.ModelAction, - ) {} - public setGeneratedName(name: string): this { - this.generatedName = name - return this + private readonly type: DMMF.OutputType, + private readonly context: GenerateContext, + private readonly action?: DMMF.ModelAction, + ) { + this.moduleExport = ts + .moduleExport( + ts.typeDeclaration(getModelArgName(type.name, action), ts.objectType()).addGenericParameter(extArgsParam), + ) + .setDocComment(ts.docComment(`${type.name} ${action ?? 'without action'}`)) } - public setComment(comment: string): this { - this.comment = comment - return this + private addProperty(prop: ts.Property) { + this.moduleExport.declaration.type.add(prop) } - public toTS(): string { - const { action, args } = this - const { name } = this.type + addSchemaArgs(args: readonly DMMF.SchemaArg[]): this { + for (const arg of args) { + const inputField = buildInputField(arg, this.context.genericArgsInfo).setDocComment( + ts.docComment(getArgFieldJSDoc(this.type, this.action, arg)), + ) - const updatedArgs = args.map((arg) => { - return { ...arg, comment: getArgFieldJSDoc(this.type, action, arg) } - }) + this.addProperty(inputField) + } + return this + } - const selectName = getSelectName(name) + addSelectArg(): this { + this.addProperty( + ts + .property( + 'select', + ts.unionType([ + ts.namedType(getSelectName(this.type.name)).addGenericArgument(extArgsParam.toArgument()), + ts.nullType, + ]), + ) + .optional() + .setDocComment(ts.docComment(`Select specific fields to fetch from the ${this.type.name}`)), + ) - const argsToGenerate: DMMF.SchemaArg[] = [ - { - name: 'select', - isRequired: false, - isNullable: true, - inputTypes: [ - { - type: selectName, - location: 'inputObjectTypes', - isList: false, - }, - { - type: 'null', - location: 'scalar', - isList: false, - }, - ], - comment: `Select specific fields to fetch from the ${name}`, - }, - ] + return this + } + addIncludeArgIfHasRelations(): this { const hasRelationField = this.type.fields.some((f) => f.outputType.location === 'outputObjectTypes') - - if (hasRelationField) { - const includeName = getIncludeName(name) - argsToGenerate.push({ - name: 'include', - isRequired: false, - isNullable: true, - inputTypes: [ - { - type: includeName, - location: 'inputObjectTypes', - isList: false, - }, - { - type: 'null', - location: 'scalar', - isList: false, - }, - ], - comment: `Choose, which related nodes to fetch as well.`, - }) + if (!hasRelationField) { + return this } - argsToGenerate.push(...updatedArgs) - if (!action && !this.generatedName) { - this.context.defaultArgsAliases.addPossibleAlias(getModelArgName(name), getLegacyModelArgName(name)) - } - const generatedName = this.generatedName ?? getModelArgName(name, action) - this.context.defaultArgsAliases.registerArgName(generatedName) + this.addProperty( + ts + .property('include', ts.unionType([ts.namedType(getIncludeName(this.type.name)), ts.nullType])) + .optional() + .setDocComment(ts.docComment('Choose, which related nodes to fetch as well')), + ) - return ` -/** - * ${this.getGeneratedComment()} - */ -export type ${generatedName} = { -${indent(argsToGenerate.map((arg) => new InputField(arg, this.context.genericArgsInfo).toTS()).join('\n'), TAB_SIZE)} -} -` + return this } - private getGeneratedComment() { - return this.comment ?? `${this.type.name} ${this.action ?? 'without action'}` + addExcludeArg(): this { + // TODO: check preview feature + this.addProperty( + ts + .property( + 'exclude', + ts.unionType([ + ts.namedType(getExcludeName(this.type.name)).addGenericArgument(extArgsParam.toArgument()), + ts.nullType, + ]), + ) + .optional() + .setDocComment(ts.docComment(`Select specific fields to fetch from the ${this.type.name}`)), + ) + return this } -} -export class MinimalArgsType implements Generatable { - constructor( - protected readonly args: readonly DMMF.SchemaArg[], - protected readonly type: DMMF.OutputType, - protected readonly context: GenerateContext, - protected readonly action?: DMMF.ModelAction, - protected readonly generatedTypeName?: string, - ) {} - public toTS(): string { - const { action, args } = this - const { name } = this.type + setGeneratedName(name: string): this { + this.hasDefaultName = false + this.moduleExport.declaration.setName(name) + return this + } - const updatedArgs = args.map((arg) => { - return { ...arg, comment: getArgFieldJSDoc(this.type, action, arg) } - }) + setComment(comment: string): this { + this.moduleExport.setDocComment(ts.docComment(comment)) + return this + } - if (!action && !this.generatedTypeName) { - this.context.defaultArgsAliases.addPossibleAlias(getModelArgName(name), getLegacyModelArgName(name)) + createExport() { + if (!this.action && this.hasDefaultName) { + this.context.defaultArgsAliases.addPossibleAlias( + getModelArgName(this.type.name), + getLegacyModelArgName(this.type.name), + ) } - const typeName = this.generatedTypeName ?? getModelArgName(name, action) - this.context.defaultArgsAliases.registerArgName(typeName) - return ` -/** - * ${name} ${action ? action : 'without action'} - */ -export type ${typeName} = { -${indent( - updatedArgs - .map((arg) => { - return new InputField(arg, this.context.genericArgsInfo).toTS() - }) - .join('\n'), - TAB_SIZE, -)} -} -` + this.context.defaultArgsAliases.registerArgName(this.moduleExport.declaration.name) + return this.moduleExport } } diff --git a/packages/client/src/generation/TSClient/Count.ts b/packages/client/src/generation/TSClient/Count.ts index c60cc980defc..8c3bddd085ae 100644 --- a/packages/client/src/generation/TSClient/Count.ts +++ b/packages/client/src/generation/TSClient/Count.ts @@ -3,30 +3,28 @@ import indent from 'indent-string' import { DMMF } from '../dmmf-types' import * as ts from '../ts-builders' import { capitalize, getFieldArgName, getSelectName } from '../utils' -import { ArgsType, MinimalArgsType } from './Args' +import { ArgsTypeBuilder } from './Args' import { TAB_SIZE } from './constants' import type { Generatable } from './Generatable' -import { TS } from './Generatable' import { GenerateContext } from './GenerateContext' import { buildOutputType } from './Output' export class Count implements Generatable { constructor(protected readonly type: DMMF.OutputType, protected readonly context: GenerateContext) {} - protected get argsTypes(): Generatable[] { - const argsTypes: Generatable[] = [] + protected get argsTypes(): ts.Export[] { + const argsTypes: ts.Export[] = [] - argsTypes.push(new ArgsType([], this.type, this.context)) + argsTypes.push( + new ArgsTypeBuilder(this.type, this.context).addSelectArg().addIncludeArgIfHasRelations().createExport(), + ) for (const field of this.type.fields) { if (field.args.length > 0) { argsTypes.push( - new MinimalArgsType( - field.args, - this.type, - this.context, - undefined, - getCountArgsType(this.type.name, field.name), - ), + new ArgsTypeBuilder(this.type, this.context) + .addSchemaArgs(field.args) + .setGeneratedName(getCountArgsType(this.type.name, field.name)) + .createExport(), ) } } @@ -69,7 +67,7 @@ ${indent( } // Custom InputTypes -${this.argsTypes.map((gen) => TS(gen)).join('\n')} +${this.argsTypes.map((typeExport) => ts.stringify(typeExport)).join('\n\n')} ` } } diff --git a/packages/client/src/generation/TSClient/Input.ts b/packages/client/src/generation/TSClient/Input.ts index 1b927cc0e935..a3cafc2c092d 100644 --- a/packages/client/src/generation/TSClient/Input.ts +++ b/packages/client/src/generation/TSClient/Input.ts @@ -20,7 +20,7 @@ export class InputField implements Generatable { } } -function buildInputField(field: DMMF.SchemaArg, genericsInfo: GenericArgsInfo, source?: string) { +export function buildInputField(field: DMMF.SchemaArg, genericsInfo: GenericArgsInfo, source?: string): ts.Property { const tsType = buildAllFieldTypes(field.inputTypes, genericsInfo, source) const tsProperty = ts.property(field.name, tsType) @@ -60,10 +60,6 @@ function buildSingleFieldType(t: DMMF.InputTypeRef, genericsInfo: GenericArgsInf type = namedInputType(scalarType ?? t.type) } - if (type.name.endsWith('Select') || type.name.endsWith('Include')) { - type.addGenericArgument(ts.namedType('ExtArgs')) - } - if (genericsInfo.typeRefNeedsGenericModelArg(t)) { if (source) { type.addGenericArgument(ts.stringLiteral(source)) diff --git a/packages/client/src/generation/TSClient/Model.ts b/packages/client/src/generation/TSClient/Model.ts index 8ee3c877d07c..5253541d75bf 100644 --- a/packages/client/src/generation/TSClient/Model.ts +++ b/packages/client/src/generation/TSClient/Model.ts @@ -27,17 +27,16 @@ import { getSumAggregateName, } from '../utils' import { InputField } from './../TSClient' -import { ArgsType, MinimalArgsType } from './Args' +import { ArgsTypeBuilder } from './Args' import { TAB_SIZE } from './constants' import type { Generatable } from './Generatable' -import { TS } from './Generatable' import { GenerateContext } from './GenerateContext' import { getArgFieldJSDoc, getArgs, getGenericMethod, getMethodJSDoc, wrapComment } from './helpers' import { InputType } from './Input' import { ModelFieldRefs } from './ModelFieldRefs' import { buildOutputType } from './Output' import { buildModelPayload } from './Payload' -import { buildIncludeType, buildScalarSelectType, buildSelectType } from './SelectInclude' +import { buildExcludeType, buildIncludeType, buildScalarSelectType, buildSelectType } from './SelectIncludeExclude' import { getModelActions } from './utils/getModelActions' export class Model implements Generatable { @@ -51,8 +50,9 @@ export class Model implements Generatable { this.type = this.context.dmmf.outputTypeMap.model[model.name] this.mapping = this.context.dmmf.mappings.modelOperations.find((m) => m.model === model.name)! } - protected get argsTypes(): Generatable[] { - const argsTypes: Generatable[] = [] + + protected get argsTypes(): ts.Export[] { + const argsTypes: ts.Export[] = [] for (const action of Object.keys(DMMF.ModelAction)) { const fieldName = this.rootFieldNameForAction(action as DMMF.ModelAction) if (!fieldName) { @@ -63,12 +63,27 @@ export class Model implements Generatable { throw new Error(`Oops this must not happen. Could not find field ${fieldName} on either Query or Mutation`) } - if (action === 'updateMany' || action === 'deleteMany' || action === 'createMany') { - argsTypes.push(new MinimalArgsType(field.args, this.type, this.context, action as DMMF.ModelAction)) - } else if (action === 'findRaw' || action === 'aggregateRaw') { - argsTypes.push(new MinimalArgsType(field.args, this.type, this.context, action as DMMF.ModelAction)) + if ( + action === 'updateMany' || + action === 'deleteMany' || + action === 'createMany' || + action === 'findRaw' || + action === 'aggregateRaw' + ) { + argsTypes.push( + new ArgsTypeBuilder(this.type, this.context, action as DMMF.ModelAction) + .addSchemaArgs(field.args) + .createExport(), + ) } else if (action !== 'groupBy' && action !== 'aggregate') { - argsTypes.push(new ArgsType(field.args, this.type, this.context, action as DMMF.ModelAction)) + argsTypes.push( + new ArgsTypeBuilder(this.type, this.context, action as DMMF.ModelAction) + .addSelectArg() + .addExcludeArg() + .addIncludeArgIfHasRelations() + .addSchemaArgs(field.args) + .createExport(), + ) } } @@ -81,13 +96,24 @@ export class Model implements Generatable { continue } argsTypes.push( - new ArgsType(field.args, fieldOutput, this.context) + new ArgsTypeBuilder(fieldOutput, this.context) + .addSelectArg() + .addExcludeArg() + .addIncludeArgIfHasRelations() + .addSchemaArgs(field.args) .setGeneratedName(getModelFieldArgsName(field, this.model.name)) - .setComment(`${this.model.name}.${field.name}`), + .setComment(`${this.model.name}.${field.name}`) + .createExport(), ) } - argsTypes.push(new ArgsType([], this.type, this.context)) + argsTypes.push( + new ArgsTypeBuilder(this.type, this.context) + .addSelectArg() + .addExcludeArg() + .addIncludeArgIfHasRelations() + .createExport(), + ) return argsTypes } @@ -308,6 +334,9 @@ ${ts.stringify(buildSelectType({ modelName: this.model.name, fields: this.type.f ${ts.stringify(buildScalarSelectType({ modelName: this.model.name, fields: this.type.fields }), { newLine: 'leading', })} +${ts.stringify(buildExcludeType({ modelName: this.model.name, fields: this.type.fields, dmmf: this.dmmf }), { + newLine: 'leading', +})} ${includeType} ${ts.stringify(buildModelPayload(this.model, this.dmmf), { newLine: 'both' })} @@ -320,7 +349,7 @@ ${isComposite ? '' : new ModelDelegate(this.type, this.context).toTS()} ${new ModelFieldRefs(this.type).toTS()} // Custom InputTypes -${this.argsTypes.map((gen) => TS(gen)).join('\n')} +${this.argsTypes.map((type) => ts.stringify(type)).join('\n\n')} ` } } @@ -355,7 +384,7 @@ export class ModelDelegate implements Generatable { const countArgsName = getModelArgName(name, DMMF.ModelAction.count) this.context.defaultArgsAliases.registerArgName(countArgsName) - const excludedArgsForCount = ['select', 'include', 'distinct'] + const excludedArgsForCount = ['select', 'include', 'exclude', 'distinct'] if (this.context.generator?.previewFeatures.includes('relationJoins')) { excludedArgsForCount.push('relationLoadStrategy') } diff --git a/packages/client/src/generation/TSClient/SelectInclude.ts b/packages/client/src/generation/TSClient/SelectIncludeExclude.ts similarity index 70% rename from packages/client/src/generation/TSClient/SelectInclude.ts rename to packages/client/src/generation/TSClient/SelectIncludeExclude.ts index 243af68fc794..cb8c9631bb50 100644 --- a/packages/client/src/generation/TSClient/SelectInclude.ts +++ b/packages/client/src/generation/TSClient/SelectIncludeExclude.ts @@ -2,7 +2,7 @@ import { DMMF } from '@prisma/generator-helper' import { DMMFHelper } from '../dmmf' import * as ts from '../ts-builders' -import { getFieldArgName, getIncludeName, getSelectName } from '../utils' +import { getExcludeName, getFieldArgName, getIncludeName, getSelectName } from '../utils' import { lowerCase } from '../utils/common' type BuildIncludeTypeParams = { @@ -21,6 +21,31 @@ export function buildIncludeType({ modelName, dmmf, fields }: BuildIncludeTypePa return buildExport(getIncludeName(modelName), type) } +type BuildExcludeTypeParams = { + modelName: string + dmmf: DMMFHelper + fields: readonly DMMF.SchemaField[] +} + +export function buildExcludeType({ modelName, fields, dmmf }: BuildExcludeTypeParams) { + const keysType = ts.unionType( + fields + .filter( + (field) => + field.outputType.location === 'scalar' || + field.outputType.location === 'enumTypes' || + dmmf.isComposite(field.outputType.type), + ) + .map((field) => ts.stringLiteral(field.name)), + ) + + keysType.addVariant(ts.keyOfType(modelResultExtensionsType(modelName))) + + const excludeType = ts.namedType('$Extensions.GetExclude').addGenericArgument(keysType) + + return buildExport(getExcludeName(modelName), excludeType) +} + type BuildSelectTypeParams = { modelName: string fields: readonly DMMF.SchemaField[] @@ -31,11 +56,15 @@ export function buildSelectType({ modelName, fields }: BuildSelectTypeParams) { const selectType = ts .namedType('$Extensions.GetSelect') .addGenericArgument(objectType) - .addGenericArgument(extArgsParameter.toArgument().subKey('result').subKey(lowerCase(modelName))) + .addGenericArgument(modelResultExtensionsType(modelName)) return buildExport(getSelectName(modelName), selectType) } +function modelResultExtensionsType(modelName: string) { + return extArgsParameter.toArgument().subKey('result').subKey(lowerCase(modelName)) +} + export function buildScalarSelectType({ modelName, fields }: BuildSelectTypeParams) { const object = buildSelectOrIncludeObject( modelName, diff --git a/packages/client/src/generation/TSClient/common.ts b/packages/client/src/generation/TSClient/common.ts index 560f9673a698..7158e648c31d 100644 --- a/packages/client/src/generation/TSClient/common.ts +++ b/packages/client/src/generation/TSClient/common.ts @@ -296,6 +296,11 @@ type SelectAndInclude = { include: any } +type SelectAndExclude = { + select: any + exclude: any +} + /** * Get the type of the value, that the Promise holds. */ @@ -344,7 +349,9 @@ export type SelectSubset = { } & (T extends SelectAndInclude ? 'Please either choose \`select\` or \`include\`.' - : {}) + : T extends SelectAndExclude + ? 'Please either choose \`select\` or \`exclude\`.' + : {}) /** * Subset + Intersection diff --git a/packages/client/src/generation/TSClient/index.ts b/packages/client/src/generation/TSClient/index.ts index 4168b42d122f..fe2b23ec2de6 100644 --- a/packages/client/src/generation/TSClient/index.ts +++ b/packages/client/src/generation/TSClient/index.ts @@ -1,6 +1,5 @@ import 'flat-map-polyfill' // unfortunately needed as it's not properly polyfilled in TypeScript -export { ArgsType, MinimalArgsType } from './Args' export { Enum } from './Enum' export { BrowserJS, JS, TS } from './Generatable' export { InputField, InputType } from './Input' diff --git a/packages/client/src/generation/ts-builders/Export.ts b/packages/client/src/generation/ts-builders/Export.ts index 8b1e6ecdc15b..dba94c8a29ee 100644 --- a/packages/client/src/generation/ts-builders/Export.ts +++ b/packages/client/src/generation/ts-builders/Export.ts @@ -3,9 +3,9 @@ import { BasicBuilder } from './BasicBuilder' import { DocComment } from './DocComment' import { Writer } from './Writer' -export class Export implements BasicBuilder { +export class Export implements BasicBuilder { private docComment?: DocComment - constructor(private declaration: AnyDeclarationBuilder) {} + constructor(public readonly declaration: Decl) {} setDocComment(docComment: DocComment): this { this.docComment = docComment @@ -20,6 +20,6 @@ export class Export implements BasicBuilder { } } -export function moduleExport(declaration: AnyDeclarationBuilder) { +export function moduleExport(declaration: Decl): Export { return new Export(declaration) } diff --git a/packages/client/src/generation/ts-builders/FunctionType.ts b/packages/client/src/generation/ts-builders/FunctionType.ts index a2d58a9eb3bd..85cbf67056fa 100644 --- a/packages/client/src/generation/ts-builders/FunctionType.ts +++ b/packages/client/src/generation/ts-builders/FunctionType.ts @@ -6,6 +6,7 @@ import { Writer } from './Writer' export class FunctionType extends TypeBuilder { needsParenthesisWhenIndexed = true + needsParenthesisInKeyof = true private returnType: TypeBuilder = voidType private parameters: Parameter[] = [] private genericParameters: GenericParameter[] = [] diff --git a/packages/client/src/generation/ts-builders/KeyofType.test.ts b/packages/client/src/generation/ts-builders/KeyofType.test.ts new file mode 100644 index 000000000000..0c5c2317f624 --- /dev/null +++ b/packages/client/src/generation/ts-builders/KeyofType.test.ts @@ -0,0 +1,32 @@ +import { array } from './ArrayType' +import { functionType } from './FunctionType' +import { keyOfType } from './KeyofType' +import { namedType } from './NamedType' +import { objectType } from './ObjectType' +import { stringify } from './stringify' +import { unionType } from './UnionType' + +const A = namedType('A') +const B = namedType('B') +const C = namedType('C') + +test('simple only', () => { + expect(stringify(keyOfType(A))).toMatchInlineSnapshot(`"keyof A"`) +}) + +test('with object type', () => { + expect(stringify(keyOfType(objectType()))).toMatchInlineSnapshot(`"keyof {}"`) +}) + +test('with array type', () => { + expect(stringify(keyOfType(array(A)))).toMatchInlineSnapshot(`"keyof A[]"`) +}) + +test('with function type', () => { + expect(stringify(keyOfType(functionType()))).toMatchInlineSnapshot(`"keyof (() => void)"`) +}) + +test('with union type', () => { + const union = unionType(A).addVariant(B).addVariant(C) + expect(stringify(keyOfType(union))).toMatchInlineSnapshot(`"keyof (A | B | C)"`) +}) diff --git a/packages/client/src/generation/ts-builders/KeyofType.ts b/packages/client/src/generation/ts-builders/KeyofType.ts new file mode 100644 index 000000000000..5bd174d553de --- /dev/null +++ b/packages/client/src/generation/ts-builders/KeyofType.ts @@ -0,0 +1,21 @@ +import { TypeBuilder } from './TypeBuilder' +import { Writer } from './Writer' + +export class KeyofType extends TypeBuilder { + constructor(public baseType: TypeBuilder) { + super() + } + + write(writer: Writer): void { + writer.write(`keyof `) + if (this.baseType.needsParenthesisInKeyof) { + writer.write('(').write(this.baseType).write(')') + } else { + writer.write(this.baseType) + } + } +} + +export function keyOfType(baseType: TypeBuilder): KeyofType { + return new KeyofType(baseType) +} diff --git a/packages/client/src/generation/ts-builders/TypeBuilder.ts b/packages/client/src/generation/ts-builders/TypeBuilder.ts index 5d38b97c6dbc..5b9113624b59 100644 --- a/packages/client/src/generation/ts-builders/TypeBuilder.ts +++ b/packages/client/src/generation/ts-builders/TypeBuilder.ts @@ -3,7 +3,10 @@ import type { KeyType } from './KeyType' import { Writer } from './Writer' export abstract class TypeBuilder implements BasicBuilder { + // TODO: this should be replaced with precedence system that would + // automatically add parenthesis where they are needed needsParenthesisWhenIndexed = false + needsParenthesisInKeyof = false abstract write(writer: Writer): void diff --git a/packages/client/src/generation/ts-builders/TypeDeclaration.ts b/packages/client/src/generation/ts-builders/TypeDeclaration.ts index c7a6a753da38..55739a458c76 100644 --- a/packages/client/src/generation/ts-builders/TypeDeclaration.ts +++ b/packages/client/src/generation/ts-builders/TypeDeclaration.ts @@ -8,13 +8,18 @@ export class TypeDeclaration implem private genericParameters: GenericParameter[] = [] private docComment?: DocComment - constructor(readonly name: string, readonly type: InnerType) {} + constructor(public name: string, readonly type: InnerType) {} addGenericParameter(param: GenericParameter): this { this.genericParameters.push(param) return this } + setName(name: string) { + this.name = name + return this + } + setDocComment(docComment: DocComment): this { this.docComment = docComment return this diff --git a/packages/client/src/generation/ts-builders/UnionType.ts b/packages/client/src/generation/ts-builders/UnionType.ts index 8fd64516cc67..8e790caa2694 100644 --- a/packages/client/src/generation/ts-builders/UnionType.ts +++ b/packages/client/src/generation/ts-builders/UnionType.ts @@ -3,6 +3,7 @@ import { Writer } from './Writer' export class UnionType extends TypeBuilder { needsParenthesisWhenIndexed = true + needsParenthesisInKeyof = true readonly variants: VariantType[] constructor(firstType: VariantType) { diff --git a/packages/client/src/generation/ts-builders/index.ts b/packages/client/src/generation/ts-builders/index.ts index 81b0cfbf3f63..a4eab77d8832 100644 --- a/packages/client/src/generation/ts-builders/index.ts +++ b/packages/client/src/generation/ts-builders/index.ts @@ -11,6 +11,7 @@ export * from './GenericParameter' export * from './helpers' export * from './Import' export * from './Interface' +export * from './KeyofType' export * from './Method' export * from './NamedType' export * from './ObjectType' diff --git a/packages/client/src/generation/utils.ts b/packages/client/src/generation/utils.ts index 5b8cf59c2d3e..664eed9e7946 100644 --- a/packages/client/src/generation/utils.ts +++ b/packages/client/src/generation/utils.ts @@ -67,6 +67,10 @@ export function getIncludeName(modelName: string): string { return `${modelName}Include` } +export function getExcludeName(modelName: string): string { + return `${modelName}Exclude` +} + export function getFieldArgName(field: DMMF.SchemaField, modelName: string): string { if (field.args.length) { return getModelFieldArgsName(field, modelName) diff --git a/packages/client/src/runtime/core/errorRendering/applyValidationError.test.ts b/packages/client/src/runtime/core/errorRendering/applyValidationError.test.ts index 902f889ab4f3..e0089f0f8b9e 100644 --- a/packages/client/src/runtime/core/errorRendering/applyValidationError.test.ts +++ b/packages/client/src/runtime/core/errorRendering/applyValidationError.test.ts @@ -45,11 +45,11 @@ const PostOutputDescription = { ], } -describe('includeAndSelect', () => { +describe('mutuallyExclusiveFields', () => { test('top level', () => { expect( renderError( - { kind: 'IncludeAndSelect', selectionPath: [] }, + { kind: 'MutuallyExclusiveFields', firstField: 'include', secondField: 'select', selectionPath: [] }, { data: { foo: 'bar' }, include: {}, @@ -91,10 +91,60 @@ describe('includeAndSelect', () => { `) }) + test('top level (exclude)', () => { + expect( + renderError( + { kind: 'MutuallyExclusiveFields', firstField: 'exclude', secondField: 'select', selectionPath: [] }, + { + data: { foo: 'bar' }, + exclude: {}, + select: {}, + }, + ), + ).toMatchInlineSnapshot(` + " + Colorless: + + { + data: { + foo: "bar" + }, + exclude: {}, + ~~~~~~~ + select: {} + ~~~~~~ + } + + Please either use \`exclude\` or \`select\`, but not both at the same time. + + ------------------------------------ + + Colored: + + { + data: { + foo: "bar" + }, + exclude: {}, + ~~~~~~~ + select: {} + ~~~~~~ + } + + Please either use \`exclude\` or \`select\`, but not both at the same time. + " + `) + }) + test('deep', () => { expect( renderError( - { kind: 'IncludeAndSelect', selectionPath: ['posts', 'likes'] }, + { + kind: 'MutuallyExclusiveFields', + firstField: 'include', + secondField: 'select', + selectionPath: ['posts', 'likes'], + }, { include: { posts: { @@ -453,6 +503,53 @@ describe('EmptySelection', () => { `) }) + test('top level (exclude)', () => { + expect( + renderError( + { + kind: 'EmptySelection', + selectionPath: [], + outputType: PostOutputDescription, + }, + { where: { published: true }, exclude: { id: true, title: true } }, + ), + ).toMatchInlineSnapshot(` + " + Colorless: + + { + where: { + published: true + }, + exclude: { + ? id?: false, + ? title?: false, + ? comments?: false + } + } + + The exclude statement includes every field of the model Post. At least one field must be included in the result + + ------------------------------------ + + Colored: + + { + where: { + published: true + }, + exclude: { + ? id?: false, + ? title?: false, + ? comments?: false + } + } + + The exclude statement includes every field of the model Post. At least one field must be included in the result + " + `) + }) + test('top level with falsy values', () => { expect( renderError( @@ -556,6 +653,63 @@ describe('EmptySelection', () => { " `) }) + + test('nested (exclude)', () => { + expect( + renderError( + { + kind: 'EmptySelection', + selectionPath: ['users', 'posts'], + outputType: PostOutputDescription, + }, + { select: { users: { include: { posts: { exclude: { id: true, title: true, comments: true } } } } } }, + ), + ).toMatchInlineSnapshot(` + " + Colorless: + + { + select: { + users: { + include: { + posts: { + exclude: { + ? id?: false, + ? title?: false, + ? comments?: false + } + } + } + } + } + } + + The exclude statement includes every field of the model Post. At least one field must be included in the result + + ------------------------------------ + + Colored: + + { + select: { + users: { + include: { + posts: { + exclude: { + ? id?: false, + ? title?: false, + ? comments?: false + } + } + } + } + } + } + + The exclude statement includes every field of the model Post. At least one field must be included in the result + " + `) + }) }) describe('UnknownSelectionField', () => { diff --git a/packages/client/src/runtime/core/errorRendering/applyValidationError.ts b/packages/client/src/runtime/core/errorRendering/applyValidationError.ts index 454ba72eecc4..4289df1943eb 100644 --- a/packages/client/src/runtime/core/errorRendering/applyValidationError.ts +++ b/packages/client/src/runtime/core/errorRendering/applyValidationError.ts @@ -15,7 +15,7 @@ import { UnknownSelectionFieldError, ValueTooLargeError, } from '../engines' -import { IncludeAndSelectError, IncludeOnScalarError, ValidationError } from '../types/ValidationError' +import { IncludeOnScalarError, MutuallyExclusiveFieldsError, ValidationError } from '../types/ValidationError' import { applyUnionError } from './applyUnionError' import { ArgumentsRenderingTree } from './ArgumentsRenderingTree' import { Colors } from './base' @@ -24,6 +24,7 @@ import { ObjectFieldSuggestion } from './ObjectFieldSuggestion' import { ObjectValue } from './ObjectValue' import { ScalarValue } from './ScalarValue' import { SuggestionObjectValue } from './SuggestionObjectValue' +import { Value } from './Value' /** * Given the validation error and arguments rendering tree, applies corresponding @@ -34,8 +35,8 @@ import { SuggestionObjectValue } from './SuggestionObjectValue' */ export function applyValidationError(error: ValidationError, args: ArgumentsRenderingTree): void { switch (error.kind) { - case 'IncludeAndSelect': - applyIncludeAndSelectError(error, args) + case 'MutuallyExclusiveFields': + applyMutuallyExclusiveFieldsError(error, args) break case 'IncludeOnScalar': applyIncludeOnScalarError(error, args) @@ -78,17 +79,17 @@ export function applyValidationError(error: ValidationError, args: ArgumentsRend } } -function applyIncludeAndSelectError(error: IncludeAndSelectError, argsTree: ArgumentsRenderingTree) { +function applyMutuallyExclusiveFieldsError(error: MutuallyExclusiveFieldsError, argsTree: ArgumentsRenderingTree) { const object = argsTree.arguments.getDeepSubSelectionValue(error.selectionPath) if (object && object instanceof ObjectValue) { - object.getField('include')?.markAsError() - object.getField('select')?.markAsError() + object.getField(error.firstField)?.markAsError() + object.getField(error.secondField)?.markAsError() } argsTree.addErrorMessage( (colors) => - `Please ${colors.bold('either')} use ${colors.green('`include`')} or ${colors.green( - '`select`', + `Please ${colors.bold('either')} use ${colors.green(`\`${error.firstField}\``)} or ${colors.green( + `\`${error.secondField}\``, )}, but ${colors.red('not both')} at the same time.`, ) } @@ -124,6 +125,40 @@ function applyIncludeOnScalarError(error: IncludeOnScalarError, argsTree: Argume } function applyEmptySelectionError(error: EmptySelectionError, argsTree: ArgumentsRenderingTree) { + const subSelection = argsTree.arguments.getDeepSubSelectionValue(error.selectionPath) + if (subSelection instanceof ObjectValue) { + const exclude = subSelection.getField('exclude') + if (exclude) { + applyEmptySelectionErrorExclude(error, argsTree, exclude.value) + return + } + } + + applyEmptySelectionErrorSelect(error, argsTree) +} + +// case for `EmptySelectionError`, triggered by excessive exclude +function applyEmptySelectionErrorExclude( + error: EmptySelectionError, + argsTree: ArgumentsRenderingTree, + excludeValue: Value, +) { + if (excludeValue instanceof ObjectValue) { + excludeValue.removeAllFields() + for (const field of error.outputType.fields) { + excludeValue.addSuggestion(new ObjectFieldSuggestion(field.name, 'false')) + } + } + + argsTree.addErrorMessage((colors) => { + return `The ${colors.red('exclude')} statement includes every field of the model ${colors.bold( + error.outputType.name, + )}. At least one field must be included in the result` + }) +} + +// case for `EmptySelectionError`, triggered by empty/falsy `select` +function applyEmptySelectionErrorSelect(error: EmptySelectionError, argsTree: ArgumentsRenderingTree) { const outputType = error.outputType const selection = argsTree.arguments.getDeepSelectionParent(error.selectionPath)?.value const isEmpty = selection?.isEmpty() ?? false diff --git a/packages/client/src/runtime/core/extensions/applyAllResultExtensions.ts b/packages/client/src/runtime/core/extensions/applyAllResultExtensions.ts index db17f01a5513..d5e817fdb570 100644 --- a/packages/client/src/runtime/core/extensions/applyAllResultExtensions.ts +++ b/packages/client/src/runtime/core/extensions/applyAllResultExtensions.ts @@ -45,6 +45,7 @@ export function applyAllResultExtensions({ result: value, modelName: dmmfToJSModelName(dmmfModelName), select: args.select, + exclude: args.exclude, extensions, }), }) diff --git a/packages/client/src/runtime/core/extensions/applyResultExtensions.test.ts b/packages/client/src/runtime/core/extensions/applyResultExtensions.test.ts index 8d0045260c50..e7f715d61071 100644 --- a/packages/client/src/runtime/core/extensions/applyResultExtensions.test.ts +++ b/packages/client/src/runtime/core/extensions/applyResultExtensions.test.ts @@ -469,3 +469,94 @@ test('allow to shadow already shadowed field', () => { }) expect(extended).toHaveProperty('firstName', 'JOHN!') }) + +test('allows to exclude computed fields', () => { + const result = { + firstName: 'John', + lastName: 'Smith', + } + + const extension = { + result: { + user: { + fullName: { + needs: { firstName: true, lastName: true }, + compute(user) { + return `${user.firstName} ${user.lastName}` + }, + }, + }, + }, + } + + const extended = applyResultExtensions({ + result, + modelName: 'user', + exclude: { fullName: true }, + extensions: MergedExtensionsList.single(extension), + }) + expect(extended).not.toHaveProperty('fullName') +}) + +test('allows to exclude dependency of a computed team', () => { + const result = { + firstName: 'John', + } + + const extension = { + result: { + user: { + loudName: { + needs: { firstName: true }, + compute(user) { + return user.firstName.toUpperCase() + }, + }, + }, + }, + } + + const extended = applyResultExtensions({ + result, + modelName: 'user', + exclude: { firstName: true }, + extensions: MergedExtensionsList.single(extension), + }) + expect(extended).not.toHaveProperty('firstName') + expect(extended).toHaveProperty('loudName', 'JOHN') +}) + +test('allows to exclude transitive dependency of a computed team', () => { + const result = { + firstName: 'John', + } + + const extension = { + result: { + user: { + loudName: { + needs: { firstName: true }, + compute(user) { + return user.firstName.toUpperCase() + }, + }, + + screamingName: { + needs: { loudName: true }, + compute(user) { + return `${user.loudName}!!!` + }, + }, + }, + }, + } + + const extended = applyResultExtensions({ + result, + modelName: 'user', + exclude: { firstName: true }, + extensions: MergedExtensionsList.single(extension), + }) + expect(extended).not.toHaveProperty('firstName') + expect(extended).toHaveProperty('screamingName', 'JOHN!!!') +}) diff --git a/packages/client/src/runtime/core/extensions/applyResultExtensions.ts b/packages/client/src/runtime/core/extensions/applyResultExtensions.ts index 5a71b259654e..c052c6c9318b 100644 --- a/packages/client/src/runtime/core/extensions/applyResultExtensions.ts +++ b/packages/client/src/runtime/core/extensions/applyResultExtensions.ts @@ -7,13 +7,14 @@ import { createCompositeProxy, removeProperties, } from '../compositeProxy' -import { Selection } from '../types/exported/JsApi' +import { Exclusion, Selection } from '../types/exported/JsApi' import { MergedExtensionsList } from './MergedExtensionsList' import { ComputedField } from './resultUtils' type ApplyExtensionsArgs = { result: object select?: Selection + exclude?: Exclusion modelName: string extensions: MergedExtensionsList } @@ -30,7 +31,7 @@ type ApplyExtensionsArgs = { * @param params * @returns */ -export function applyResultExtensions({ result, modelName, select, extensions }: ApplyExtensionsArgs) { +export function applyResultExtensions({ result, modelName, select, exclude, extensions }: ApplyExtensionsArgs) { const computedFields = extensions.getAllComputedFields(modelName) if (!computedFields) { return result @@ -40,7 +41,15 @@ export function applyResultExtensions({ result, modelName, select, extensions }: const maskingLayers: CompositeProxyLayer[] = [] for (const field of Object.values(computedFields)) { - if (select) { + if (exclude) { + if (exclude[field.name]) { + continue + } + const toMask = field.needs.filter((prop) => exclude[prop]) + if (toMask.length > 0) { + maskingLayers.push(removeProperties(toMask)) + } + } else if (select) { if (!select[field.name]) { continue } diff --git a/packages/client/src/runtime/core/extensions/resultUtils.spec.ts b/packages/client/src/runtime/core/extensions/resultUtils.test.ts similarity index 85% rename from packages/client/src/runtime/core/extensions/resultUtils.spec.ts rename to packages/client/src/runtime/core/extensions/resultUtils.test.ts index cc102af2efb8..7d0c97500566 100644 --- a/packages/client/src/runtime/core/extensions/resultUtils.spec.ts +++ b/packages/client/src/runtime/core/extensions/resultUtils.test.ts @@ -1,4 +1,4 @@ -import { applyComputedFieldsToSelection, getComputedFields } from './resultUtils' +import { applyComputedFieldsToExclusion, applyComputedFieldsToSelection, getComputedFields } from './resultUtils' describe('getAllComputedFields', () => { test('returns all dependencies of an extension', () => { @@ -224,3 +224,29 @@ describe('applyComputedFieldsToSelection', () => { }) }) }) + +describe('applyComputedFieldsToExclusion', () => { + test('removes computed field dependencies from an exclusion', () => { + const fields = { + fullName: { name: 'fullName', needs: ['firstName', 'lastName'], compute: jest.fn() }, + } + const exclusion = { firstName: true, age: true } + + expect(applyComputedFieldsToExclusion(exclusion, fields)).toEqual({ + age: true, + }) + }) + + test('does not remove dependencies if computed field is excluded as well', () => { + const fields = { + fullName: { name: 'fullName', needs: ['firstName', 'lastName'], compute: jest.fn() }, + } + const exclusion = { fullName: true, firstName: true, lastName: true } + + expect(applyComputedFieldsToExclusion(exclusion, fields)).toEqual({ + fullName: true, + firstName: true, + lastName: true, + }) + }) +}) diff --git a/packages/client/src/runtime/core/extensions/resultUtils.ts b/packages/client/src/runtime/core/extensions/resultUtils.ts index d5a4a636e89e..c27258f7a587 100644 --- a/packages/client/src/runtime/core/extensions/resultUtils.ts +++ b/packages/client/src/runtime/core/extensions/resultUtils.ts @@ -3,7 +3,7 @@ import { mapObjectValues } from '@prisma/internals' import { Cache } from '../../../generation/Cache' import { dmmfToJSModelName } from '../model/utils/dmmfToJSModelName' import { ExtensionArgs, ResultArg, ResultArgsFieldCompute } from '../types/exported/ExtensionArgs' -import { Selection } from '../types/exported/JsApi' +import { Exclusion, Selection } from '../types/exported/JsApi' export type ComputedField = { name: string @@ -122,3 +122,24 @@ export function applyComputedFieldsToSelection( } return result } + +export function applyComputedFieldsToExclusion( + exclusion: Exclusion, + computedFields: ComputedFieldsMap | undefined, +): Selection { + if (!computedFields) { + return exclusion + } + const result = { ...exclusion } + + for (const field of Object.values(computedFields)) { + if (exclusion[field.name]) { + continue + } + + for (const dependency of field.needs) { + delete result[dependency] + } + } + return result +} diff --git a/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.test.ts b/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.test.ts index 3ba03759e0f3..af03e29736f7 100644 --- a/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.test.ts +++ b/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.test.ts @@ -975,3 +975,145 @@ test('explicit selection shadowing a field', () => { }" `) }) + +test('exclude', () => { + expect( + serialize({ + modelName: 'User', + action: 'findMany', + args: { exclude: { name: true } }, + }), + ).toMatchInlineSnapshot(` + "{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": {}, + "selection": { + "$composites": true, + "$scalars": true, + "name": false + } + } + }" + `) +}) + +test('exclude + include', () => { + expect( + serialize({ + modelName: 'User', + action: 'findMany', + args: { include: { posts: true }, exclude: { name: true } }, + }), + ).toMatchInlineSnapshot(` + "{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": {}, + "selection": { + "$composites": true, + "$scalars": true, + "posts": true, + "name": false + } + } + }" + `) +}) + +test('nested exclude', () => { + expect( + serialize({ + modelName: 'User', + action: 'findMany', + args: { include: { posts: { exclude: { title: true } } } }, + }), + ).toMatchInlineSnapshot(` + "{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": {}, + "selection": { + "$composites": true, + "$scalars": true, + "posts": { + "arguments": {}, + "selection": { + "$composites": true, + "$scalars": true, + "title": false + } + } + } + } + }" + `) +}) + +test('exclusion with extension', () => { + expect( + serialize({ + modelName: 'User', + action: 'findMany', + args: { exclude: { name: true } }, + extensions: MergedExtensionsList.single({ + result: { + user: { + fullName: { + needs: { name: true }, + compute: jest.fn(), + }, + }, + }, + }), + }), + ).toMatchInlineSnapshot(` + "{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": {}, + "selection": { + "$composites": true, + "$scalars": true + } + } + }" + `) +}) + +test('exclusion with extension while excluding computed field too', () => { + expect( + serialize({ + modelName: 'User', + action: 'findMany', + args: { exclude: { name: true, fullName: true } }, + extensions: MergedExtensionsList.single({ + result: { + user: { + fullName: { + needs: { name: true }, + compute: jest.fn(), + }, + }, + }, + }), + }), + ).toMatchInlineSnapshot(` + "{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": {}, + "selection": { + "$composites": true, + "$scalars": true, + "name": false + } + } + }" + `) +}) diff --git a/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.ts b/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.ts index eb11e67ab657..3f61c7dffc99 100644 --- a/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.ts +++ b/packages/client/src/runtime/core/jsonProtocol/serializeJsonQuery.ts @@ -13,10 +13,18 @@ import { } from '../engines' import { throwValidationException } from '../errorRendering/throwValidationException' import { MergedExtensionsList } from '../extensions/MergedExtensionsList' -import { applyComputedFieldsToSelection } from '../extensions/resultUtils' +import { applyComputedFieldsToExclusion, applyComputedFieldsToSelection } from '../extensions/resultUtils' import { isFieldRef } from '../model/FieldRef' import { RuntimeDataModel, RuntimeModel } from '../runtimeDataModel' -import { Action, JsArgs, JsInputValue, JsonConvertible, RawParameters, Selection } from '../types/exported/JsApi' +import { + Action, + Exclusion, + JsArgs, + JsInputValue, + JsonConvertible, + RawParameters, + Selection, +} from '../types/exported/JsApi' import { ObjectEnumValue, objectEnumValues } from '../types/exported/ObjectEnums' import { ValidationError } from '../types/ValidationError' @@ -87,32 +95,48 @@ export function serializeJsonQuery({ } function serializeFieldSelection( - { select, include, ...args }: JsArgs = {}, + { select, include, exclude, ...args }: JsArgs = {}, context: SerializeContext, ): JsonFieldSelection { return { arguments: serializeArgumentsObject(args, context), - selection: serializeSelectionSet(select, include, context), + selection: serializeSelectionSet(select, include, exclude, context), } } function serializeSelectionSet( select: Selection | undefined, include: Selection | undefined, + exclude: Record | undefined, context: SerializeContext, ): JsonSelectionSet { - if (select && include) { - context.throwValidationError({ kind: 'IncludeAndSelect', selectionPath: context.getSelectionPath() }) - } - if (select) { + if (include) { + context.throwValidationError({ + kind: 'MutuallyExclusiveFields', + firstField: 'include', + secondField: 'select', + selectionPath: context.getSelectionPath(), + }) + } else if (exclude) { + context.throwValidationError({ + kind: 'MutuallyExclusiveFields', + firstField: 'exclude', + secondField: 'select', + selectionPath: context.getSelectionPath(), + }) + } return createExplicitSelection(select, context) } - return createImplicitSelection(context, include) + return createImplicitSelection(context, include, exclude) } -function createImplicitSelection(context: SerializeContext, include: Selection | undefined) { +function createImplicitSelection( + context: SerializeContext, + include: Selection | undefined, + exclude: Record | undefined, +) { const selectionSet: JsonSelectionSet = {} if (context.model && !context.isRawAction()) { @@ -124,6 +148,10 @@ function createImplicitSelection(context: SerializeContext, include: Selection | addIncludedRelations(selectionSet, include, context) } + if (exclude) { + excludeFields(selectionSet, exclude, context) + } + return selectionSet } @@ -147,6 +175,20 @@ function addIncludedRelations(selectionSet: JsonSelectionSet, include: Selection } } +function excludeFields(selectionSet: JsonSelectionSet, exclude: Exclusion, context: SerializeContext) { + const computedFields = context.getComputedFields() + const excludeWithComputedFields = applyComputedFieldsToExclusion(exclude, computedFields) + for (const [key, value] of Object.entries(excludeWithComputedFields)) { + const field = context.findField(key) + if (computedFields?.[key] && !field) { + continue + } + if (value) { + selectionSet[key] = false + } + } +} + function createExplicitSelection(select: Selection, context: SerializeContext) { const selectionSet: JsonSelectionSet = {} const computedFields = context.getComputedFields() diff --git a/packages/client/src/runtime/core/types/ValidationError.ts b/packages/client/src/runtime/core/types/ValidationError.ts index c1be394b4071..4aec71ea2df5 100644 --- a/packages/client/src/runtime/core/types/ValidationError.ts +++ b/packages/client/src/runtime/core/types/ValidationError.ts @@ -9,10 +9,12 @@ import { EngineValidationError, OutputTypeDescription } from '../engines' */ /** - * `include` and `select` are used at the same time + * Pair of mutually exclusive fields are found on selection (for example select + include or select + exclude) */ -export type IncludeAndSelectError = { - kind: 'IncludeAndSelect' +export type MutuallyExclusiveFieldsError = { + kind: 'MutuallyExclusiveFields' + firstField: string + secondField: string selectionPath: string[] } @@ -24,4 +26,4 @@ export type IncludeOnScalarError = { selectionPath: string[] outputType?: OutputTypeDescription } -export type ValidationError = IncludeAndSelectError | IncludeOnScalarError | EngineValidationError +export type ValidationError = MutuallyExclusiveFieldsError | IncludeOnScalarError | EngineValidationError diff --git a/packages/client/src/runtime/core/types/exported/Extensions.ts b/packages/client/src/runtime/core/types/exported/Extensions.ts index e1301e5090e8..4085a0f9745a 100644 --- a/packages/client/src/runtime/core/types/exported/Extensions.ts +++ b/packages/client/src/runtime/core/types/exported/Extensions.ts @@ -41,6 +41,9 @@ export type GetSelect< KR extends keyof R = string extends keyof R ? never : keyof R, > = { [K in KR | keyof Base]?: K extends KR ? boolean : Base[K] } +export type GetExclude = { + [K in Keys]?: boolean +} /** Query */ // prettier-ignore diff --git a/packages/client/src/runtime/core/types/exported/JsApi.ts b/packages/client/src/runtime/core/types/exported/JsApi.ts index d4befb781865..edcef3013808 100644 --- a/packages/client/src/runtime/core/types/exported/JsApi.ts +++ b/packages/client/src/runtime/core/types/exported/JsApi.ts @@ -31,11 +31,14 @@ export interface JsonConvertible { export type JsArgs = { select?: Selection include?: Selection + exclude?: Exclusion [argName: string]: JsInputValue } export type Selection = Record +export type Exclusion = Record + export type RawParameters = { __prismaRawParameters__: true values: string diff --git a/packages/client/src/runtime/core/types/exported/Result.ts b/packages/client/src/runtime/core/types/exported/Result.ts index 6761715bcda1..50cf88a1a0ba 100644 --- a/packages/client/src/runtime/core/types/exported/Result.ts +++ b/packages/client/src/runtime/core/types/exported/Result.ts @@ -1,7 +1,7 @@ /* eslint-disable prettier/prettier */ import { OperationPayload } from './Payload' -import { Equals, JsonObject, Select } from './Utils' +import { Compute, Equals, JsonObject, Omit, Select } from './Utils' // prettier-ignore export type Operation = @@ -48,7 +48,16 @@ export type FluentOperation = export type Count = { [K in keyof O]: Count } & {} // prettier-ignore -export type GetFindResult

= +export type TrueKeys = { + [K in keyof T]: T[K] extends true ? K : never +}[keyof T] + +export type GetFindResult

= A extends { exclude: infer Exclusion } + ? Compute, TrueKeys>> + : GetSelectIncludeResult + +// prettier-ignore +export type GetSelectIncludeResult

= Equals extends 1 ? DefaultSelection

: A extends | { select: infer S extends object } & Record diff --git a/packages/client/tests/functional/exclude/_matrix.ts b/packages/client/tests/functional/exclude/_matrix.ts new file mode 100644 index 000000000000..b7b968e320e7 --- /dev/null +++ b/packages/client/tests/functional/exclude/_matrix.ts @@ -0,0 +1,13 @@ +import { defineMatrix } from '../_utils/defineMatrix' +import { Providers } from '../_utils/providers' + +export default defineMatrix(() => [ + [ + { provider: Providers.SQLITE }, + { provider: Providers.POSTGRESQL }, + { provider: Providers.MYSQL }, + { provider: Providers.MONGODB }, + { provider: Providers.COCKROACHDB }, + { provider: Providers.SQLSERVER }, + ], +]) diff --git a/packages/client/tests/functional/exclude/prisma/_schema.ts b/packages/client/tests/functional/exclude/prisma/_schema.ts new file mode 100644 index 000000000000..8563155a2301 --- /dev/null +++ b/packages/client/tests/functional/exclude/prisma/_schema.ts @@ -0,0 +1,30 @@ +import { foreignKeyForProvider, idForProvider } from '../../_utils/idForProvider' +import testMatrix from '../_matrix' + +export default testMatrix.setupSchema(({ provider }) => { + return /* Prisma */ ` + generator client { + provider = "prisma-client-js" + } + + datasource db { + provider = "${provider}" + url = env("DATABASE_URI_${provider}") + } + + model User { + id ${idForProvider(provider)} + name String + password String + email String @unique + posts Post[] + } + + model Post { + id ${idForProvider(provider)} + title String + authorId ${foreignKeyForProvider(provider)} + author User @relation(fields: [authorId], references: [id]) + } + ` +}) diff --git a/packages/client/tests/functional/exclude/test.ts b/packages/client/tests/functional/exclude/test.ts new file mode 100644 index 000000000000..320f0354dc2c --- /dev/null +++ b/packages/client/tests/functional/exclude/test.ts @@ -0,0 +1,360 @@ +import { expectTypeOf } from 'expect-type' + +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './node_modules/@prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite(() => { + test('exclude + select throws validation error', async () => { + // @ts-expect-error + const result = prisma.user.findFirstOrThrow({ + select: { + name: true, + }, + exclude: { + password: true, + }, + }) + + await expect(result).rejects.toMatchPrismaErrorInlineSnapshot(` + " + Invalid \`prisma.user.findFirstOrThrow()\` invocation in + /client/tests/functional/exclude/test.ts:0:0 + + XX testMatrix.setupTestSuite(() => { + XX test('exclude + select throws validation error', async () => { + XX // @ts-expect-error + → XX const result = prisma.user.findFirstOrThrow({ + select: { + ~~~~~~ + name: true + }, + exclude: { + ~~~~~~~ + password: true + } + }) + + Please either use \`exclude\` or \`select\`, but not both at the same time." + `) + }) + + test('deeply nested exclude + select throws validation error', async () => { + const result = prisma.user.findFirstOrThrow({ + select: { + name: true, + posts: { + select: { id: true }, + exclude: { title: true }, + }, + }, + }) + + await expect(result).rejects.toMatchPrismaErrorInlineSnapshot(` + " + Invalid \`prisma.user.findFirstOrThrow()\` invocation in + /client/tests/functional/exclude/test.ts:0:0 + + XX }) + XX + XX test('deeply nested exclude + select throws validation error', async () => { + → XX const result = prisma.user.findFirstOrThrow({ + select: { + name: true, + posts: { + select: { + ~~~~~~ + id: true + }, + exclude: { + ~~~~~~~ + title: true + } + } + } + }) + + Please either use \`exclude\` or \`select\`, but not both at the same time." + `) + }) + + test('excluding all fields of a model throws validation error', async () => { + const result = prisma.user.findFirstOrThrow({ + exclude: { + id: true, + name: true, + email: true, + password: true, + }, + }) + + await expect(result).rejects.toMatchPrismaErrorInlineSnapshot(` + " + Invalid \`prisma.user.findFirstOrThrow()\` invocation in + /client/tests/functional/exclude/test.ts:0:0 + + XX }) + XX + XX test('excluding all fields of a model throws validation error', async () => { + → XX const result = prisma.user.findFirstOrThrow({ + exclude: { + ? id?: false, + ? name?: false, + ? password?: false, + ? email?: false, + ? posts?: false, + ? _count?: false + } + }) + + The exclude statement includes every field of the model User. At least one field must be included in the result" + `) + }) + + test('create', async () => { + const user = await prisma.user.create({ + data: { + name: 'Steve the Rat', + password: 'cheese', + email: 'steve@rats.com', + posts: { + create: { + title: '100 places to visit before you die', + }, + }, + }, + exclude: { + password: true, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).not.toHaveProperty('password') + }) + + test('findUnique', async () => { + const user = await prisma.user.findUnique({ + where: { + email: 'steve@rats.com', + }, + exclude: { + password: true, + }, + }) + + expect(user!.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user!).toHaveProperty('id') + expectTypeOf(user!).toHaveProperty('name') + expectTypeOf(user!).not.toHaveProperty('password') + }) + + test('findFirst', async () => { + const user = await prisma.user.findFirst({ + exclude: { + password: true, + }, + }) + + expect(user?.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user!).toHaveProperty('id') + expectTypeOf(user!).toHaveProperty('name') + expectTypeOf(user!).not.toHaveProperty('password') + }) + + test('findFirstOrThrow', async () => { + const user = await prisma.user.findFirstOrThrow({ + exclude: { + password: true, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).not.toHaveProperty('password') + }) + + test('findUniqueOrThrow', async () => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + email: 'steve@rats.com', + }, + exclude: { + password: true, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).not.toHaveProperty('password') + }) + + test('update', async () => { + const user = await prisma.user.update({ + where: { + email: 'steve@rats.com', + }, + data: { + email: 'steven@rats.com', + }, + exclude: { + password: true, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).not.toHaveProperty('password') + }) + + test('upsert', async () => { + const user = await prisma.user.upsert({ + where: { + email: 'steven@rats.com', + }, + update: {}, + create: { + name: 'Steve the Rat', + password: 'cheese', + email: 'steven@rats.com', + }, + exclude: { + password: true, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user).not.toHaveProperty('password') + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).not.toHaveProperty('password') + }) + + test('false value', async () => { + const user = await prisma.user.findFirstOrThrow({ + exclude: { + password: false, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user.password).toBe('cheese') + }) + + test('exclude combined with include', async () => { + const user = await prisma.user.findFirstOrThrow({ + include: { + posts: true, + }, + exclude: { + password: true, + }, + }) + + expect(user.name).toBe('Steve the Rat') + expect(user.posts).toHaveLength(1) + expectTypeOf(user).toHaveProperty('posts') + expectTypeOf(user).not.toHaveProperty('password') + }) + + test('exclude nested in select', async () => { + const post = await prisma.post.findFirstOrThrow({ + select: { + author: { + exclude: { + password: true, + }, + }, + }, + }) + + expect(post.author.name).toBe('Steve the Rat') + expect(post.author).not.toHaveProperty('password') + expectTypeOf(post.author).not.toHaveProperty('password') + }) + + test('exclude nested in include', async () => { + const post = await prisma.post.findFirstOrThrow({ + include: { + author: { + exclude: { + password: true, + }, + }, + }, + }) + + expect(post.author.name).toBe('Steve the Rat') + expect(post.author).not.toHaveProperty('password') + expectTypeOf(post.author).not.toHaveProperty('password') + }) + + test('excluding computed fields', async () => { + const xprisma = prisma.$extends({ + result: { + user: { + superSecretInfo: { + needs: {}, + compute() { + return "it's a secret to everybody" + }, + }, + }, + }, + }) + + const user = await xprisma.user.findFirstOrThrow({ + exclude: { + superSecretInfo: true, + }, + }) + + expect(user).not.toHaveProperty('superSecretInfo') + + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).toHaveProperty('password') + expectTypeOf(user).not.toHaveProperty('superSecretInfo') + }) + + test('excluding dependency of a computed field', async () => { + const xprisma = prisma.$extends({ + result: { + user: { + sanitizedPassword: { + needs: { password: true }, + compute(user) { + return `secret(${user.password})` + }, + }, + }, + }, + }) + + const user = await xprisma.user.findFirstOrThrow({ + exclude: { + password: true, + }, + }) + + expect(user).not.toHaveProperty('password') + expect(user.sanitizedPassword).toBe('secret(cheese)') + + expectTypeOf(user).toHaveProperty('id') + expectTypeOf(user).toHaveProperty('name') + expectTypeOf(user).not.toHaveProperty('password') + expectTypeOf(user).toHaveProperty('sanitizedPassword') + }) +})