diff --git a/package.json b/package.json index a0350c892..40dfc22c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.5.1", + "version": "2.5.2", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 4de7e00da..529423588 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.5.1" +version = "2.5.2" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 996d97e52..42a1c8ca0 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.5.1", + "version": "2.5.2", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index 70391df8f..d64991021 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.5.1", + "version": "2.5.2", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index 48dc879e0..13eabd54b 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "2.5.1", + "version": "2.5.2", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index b8d5a5bdb..85a51f17b 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "2.5.1", + "version": "2.5.2", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 0f2c93588..ffbd5a8bc 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "2.5.1", + "version": "2.5.2", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 1dd116b90..42a20d2df 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "2.5.1", + "version": "2.5.2", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 89053c149..168582896 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "2.5.1", + "version": "2.5.2", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 923f76884..009eab5db 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.5.1", + "version": "2.5.2", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", @@ -98,6 +98,7 @@ "semver": "^7.5.2", "superjson": "^1.13.0", "tiny-invariant": "^1.3.1", + "traverse": "^0.6.10", "ts-pattern": "^4.3.0", "tslib": "^2.4.1", "upper-case-first": "^2.0.2", @@ -118,6 +119,7 @@ "@types/pluralize": "^0.0.29", "@types/safe-json-stringify": "^1.1.5", "@types/semver": "^7.3.13", + "@types/traverse": "^0.6.37", "@types/uuid": "^8.3.4" } } diff --git a/packages/runtime/src/cross/nested-write-visitor.ts b/packages/runtime/src/cross/nested-write-visitor.ts index 4ce4e0ae7..c69f9d203 100644 --- a/packages/runtime/src/cross/nested-write-visitor.ts +++ b/packages/runtime/src/cross/nested-write-visitor.ts @@ -169,6 +169,7 @@ export class NestedWriteVisitor { break; case 'createMany': + case 'createManyAndReturn': if (data) { const newContext = pushNewContext(field, model, {}); let callbackResult: any; diff --git a/packages/runtime/src/cross/types.ts b/packages/runtime/src/cross/types.ts index 614865f14..0466df447 100644 --- a/packages/runtime/src/cross/types.ts +++ b/packages/runtime/src/cross/types.ts @@ -4,6 +4,7 @@ export const PrismaWriteActions = [ 'create', 'createMany', + 'createManyAndReturn', 'connectOrCreate', 'update', 'updateMany', diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index 127574e26..263e12192 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -1,13 +1,19 @@ import semver from 'semver'; import { PRISMA_MINIMUM_VERSION } from '../../constants'; import { isDelegateModel, type ModelMeta } from '../../cross'; -import type { EnhancementContext, EnhancementKind, EnhancementOptions, ZodSchemas } from '../../types'; +import type { + DbClientContract, + EnhancementContext, + EnhancementKind, + EnhancementOptions, + ZodSchemas, +} from '../../types'; import { withDefaultAuth } from './default-auth'; import { withDelegate } from './delegate'; import { Logger } from './logger'; import { withOmit } from './omit'; import { withPassword } from './password'; -import { withPolicy } from './policy'; +import { policyProcessIncludeRelationPayload, withPolicy } from './policy'; import type { PolicyDef } from './types'; /** @@ -41,6 +47,18 @@ export type InternalEnhancementOptions = EnhancementOptions & { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any prismaModule: any; + + /** + * A callback shared among enhancements to process the payload for including a relation + * field. e.g.: `{ author: true }`. + */ + processIncludeRelationPayload?: ( + prisma: DbClientContract, + model: string, + payload: unknown, + options: InternalEnhancementOptions, + context: EnhancementContext | undefined + ) => Promise; }; /** @@ -89,7 +107,7 @@ export function createEnhancement( 'Your ZModel contains delegate models but "delegate" enhancement kind is not enabled. This may result in unexpected behavior.' ); } else { - result = withDelegate(result, options); + result = withDelegate(result, options, context); } } @@ -103,6 +121,16 @@ export function createEnhancement( // 'policy' and 'validation' enhancements are both enabled by `withPolicy` if (kinds.includes('policy') || kinds.includes('validation')) { result = withPolicy(result, options, context); + + // if any enhancement is to introduce an inclusion of a relation field, the + // inclusion payload must be processed by the policy enhancement for injecting + // access control rules + + // TODO: this is currently a global callback shared among all enhancements, which + // is far from ideal + + options.processIncludeRelationPayload = policyProcessIncludeRelationPayload; + if (kinds.includes('policy') && hasDefaultAuth) { // @default(auth()) proxy result = withDefaultAuth(result, options, context); diff --git a/packages/runtime/src/enhancements/node/default-auth.ts b/packages/runtime/src/enhancements/node/default-auth.ts index 10f4f3504..3852069c8 100644 --- a/packages/runtime/src/enhancements/node/default-auth.ts +++ b/packages/runtime/src/enhancements/node/default-auth.ts @@ -49,7 +49,14 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { // base override protected async preprocessArgs(action: PrismaProxyActions, args: any) { - const actionsOfInterest: PrismaProxyActions[] = ['create', 'createMany', 'update', 'updateMany', 'upsert']; + const actionsOfInterest: PrismaProxyActions[] = [ + 'create', + 'createMany', + 'createManyAndReturn', + 'update', + 'updateMany', + 'upsert', + ]; if (actionsOfInterest.includes(action)) { const newArgs = await this.preprocessWritePayload(this.model, action as PrismaWriteActionType, args); return newArgs; diff --git a/packages/runtime/src/enhancements/node/delegate.ts b/packages/runtime/src/enhancements/node/delegate.ts index 8efad7568..45e74ee36 100644 --- a/packages/runtime/src/enhancements/node/delegate.ts +++ b/packages/runtime/src/enhancements/node/delegate.ts @@ -2,6 +2,7 @@ import deepmerge, { type ArrayMergeOptions } from 'deepmerge'; import { isPlainObject } from 'is-plain-object'; import { lowerCaseFirst } from 'lower-case-first'; +import traverse from 'traverse'; import { DELEGATE_AUX_RELATION_PREFIX } from '../../constants'; import { FieldInfo, @@ -14,18 +15,22 @@ import { isDelegateModel, resolveField, } from '../../cross'; -import type { CrudContract, DbClientContract } from '../../types'; +import type { CrudContract, DbClientContract, EnhancementContext } from '../../types'; import type { InternalEnhancementOptions } from './create-enhancement'; import { Logger } from './logger'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; import { QueryUtils } from './query-utils'; import { formatObject, prismaClientValidationError } from './utils'; -export function withDelegate(prisma: DbClient, options: InternalEnhancementOptions): DbClient { +export function withDelegate( + prisma: DbClient, + options: InternalEnhancementOptions, + context: EnhancementContext | undefined +): DbClient { return makeProxy( prisma, options.modelMeta, - (_prisma, model) => new DelegateProxyHandler(_prisma as DbClientContract, model, options), + (_prisma, model) => new DelegateProxyHandler(_prisma as DbClientContract, model, options, context), 'delegate' ); } @@ -34,7 +39,12 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { private readonly logger: Logger; private readonly queryUtils: QueryUtils; - constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + constructor( + prisma: DbClientContract, + model: string, + options: InternalEnhancementOptions, + private readonly context: EnhancementContext | undefined + ) { super(prisma, model, options); this.logger = new Logger(prisma); this.queryUtils = new QueryUtils(prisma, this.options); @@ -75,7 +85,11 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { args = args ? clone(args) : {}; this.injectWhereHierarchy(model, args?.where); - this.injectSelectIncludeHierarchy(model, args); + await this.injectSelectIncludeHierarchy(model, args); + + // discriminator field is needed during post process to determine the + // actual concrete model type + this.ensureDiscriminatorSelection(model, args); if (args.orderBy) { // `orderBy` may contain fields from base types @@ -94,6 +108,23 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { } } + private ensureDiscriminatorSelection(model: string, args: any) { + const modelInfo = getModelInfo(this.options.modelMeta, model); + if (!modelInfo?.discriminator) { + return; + } + + if (args.select && typeof args.select === 'object') { + args.select[modelInfo.discriminator] = true; + return; + } + + if (args.omit && typeof args.omit === 'object') { + args.omit[modelInfo.discriminator] = false; + return; + } + } + private injectWhereHierarchy(model: string, where: any) { if (!where || !isPlainObject(where)) { return; @@ -144,7 +175,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { }); } - private injectSelectIncludeHierarchy(model: string, args: any) { + private async injectSelectIncludeHierarchy(model: string, args: any) { if (!args || typeof args !== 'object') { return; } @@ -164,25 +195,29 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { // make sure the payload is an object args[kind][field] = {}; } - this.injectSelectIncludeHierarchy(fieldInfo.type, args[kind][field]); + await this.injectSelectIncludeHierarchy(fieldInfo.type, args[kind][field]); } } - if (value !== undefined) { - if (value?.orderBy) { + // refetch the field select/include value because it may have been + // updated during injection + const fieldValue = args[kind][field]; + + if (fieldValue !== undefined) { + if (fieldValue.orderBy) { // `orderBy` may contain fields from base types - this.injectWhereHierarchy(fieldInfo.type, value.orderBy); + this.injectWhereHierarchy(fieldInfo.type, fieldValue.orderBy); } - if (this.injectBaseFieldSelect(model, field, value, args, kind)) { + if (this.injectBaseFieldSelect(model, field, fieldValue, args, kind)) { delete args[kind][field]; } else if (fieldInfo.isDataModel) { - let nextValue = value; + let nextValue = fieldValue; if (nextValue === true) { // make sure the payload is an object args[kind][field] = nextValue = {}; } - this.injectSelectIncludeHierarchy(fieldInfo.type, nextValue); + await this.injectSelectIncludeHierarchy(fieldInfo.type, nextValue); } } } @@ -194,11 +229,11 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { this.injectBaseIncludeRecursively(model, args); // include sub models downwards - this.injectConcreteIncludeRecursively(model, args); + await this.injectConcreteIncludeRecursively(model, args); } } - private buildSelectIncludeHierarchy(model: string, args: any) { + private async buildSelectIncludeHierarchy(model: string, args: any) { args = clone(args); const selectInclude: any = this.extractSelectInclude(args) || {}; @@ -222,7 +257,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { if (!selectInclude.select) { this.injectBaseIncludeRecursively(model, selectInclude); - this.injectConcreteIncludeRecursively(model, selectInclude); + await this.injectConcreteIncludeRecursively(model, selectInclude); } return selectInclude; } @@ -293,7 +328,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { this.injectBaseIncludeRecursively(base.name, selectInclude.include[baseRelationName]); } - private injectConcreteIncludeRecursively(model: string, selectInclude: any) { + private async injectConcreteIncludeRecursively(model: string, selectInclude: any) { const modelInfo = getModelInfo(this.options.modelMeta, model); if (!modelInfo) { return; @@ -307,13 +342,27 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { for (const subModel of subModels) { // include sub model relation field const subRelationName = this.makeAuxRelationName(subModel); + const includePayload: any = {}; + + if (this.options.processIncludeRelationPayload) { + // use the callback in options to process the include payload, so enhancements + // like 'policy' can do extra work (e.g., inject policy rules) + await this.options.processIncludeRelationPayload( + this.prisma, + subModel.name, + includePayload, + this.options, + this.context + ); + } + if (selectInclude.select) { - selectInclude.include = { [subRelationName]: {}, ...selectInclude.select }; + selectInclude.include = { [subRelationName]: includePayload, ...selectInclude.select }; delete selectInclude.select; } else { - selectInclude.include = { [subRelationName]: {}, ...selectInclude.include }; + selectInclude.include = { [subRelationName]: includePayload, ...selectInclude.include }; } - this.injectConcreteIncludeRecursively(subModel.name, selectInclude.include[subRelationName]); + await this.injectConcreteIncludeRecursively(subModel.name, selectInclude.include[subRelationName]); } } @@ -333,6 +382,8 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { ); } + this.sanitizeMutationPayload(args.data); + if (isDelegateModel(this.options.modelMeta, this.model)) { throw prismaClientValidationError( this.prisma, @@ -348,6 +399,24 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { return this.doCreate(this.prisma, this.model, args); } + private sanitizeMutationPayload(data: any) { + if (!data) { + return; + } + + const prisma = this.prisma; + const prismaModule = this.options.prismaModule; + traverse(data).forEach(function () { + if (this.key?.startsWith(DELEGATE_AUX_RELATION_PREFIX)) { + throw prismaClientValidationError( + prisma, + prismaModule, + `Auxiliary relation field "${this.key}" cannot be set directly` + ); + } + }); + } + override createMany(args: { data: any; skipDuplicates?: boolean }): Promise<{ count: number }> { if (!args) { throw prismaClientValidationError(this.prisma, this.options.prismaModule, 'query argument is required'); @@ -360,6 +429,8 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { ); } + this.sanitizeMutationPayload(args.data); + if (!this.involvesDelegateModel(this.model)) { return super.createMany(args); } @@ -399,6 +470,8 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { ); } + this.sanitizeMutationPayload(args.data); + if (!this.involvesDelegateModel(this.model)) { return super.createManyAndReturn(args); } @@ -430,7 +503,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { args = clone(args); await this.injectCreateHierarchy(model, args); - this.injectSelectIncludeHierarchy(model, args); + await this.injectSelectIncludeHierarchy(model, args); if (this.options.logPrismaQuery) { this.logger.info(`[delegate] \`create\` ${this.getModelName(model)}: ${formatObject(args)}`); @@ -585,6 +658,8 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { ); } + this.sanitizeMutationPayload(args.data); + if (!this.involvesDelegateModel(this.model)) { return super.update(args); } @@ -604,6 +679,8 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { ); } + this.sanitizeMutationPayload(args.data); + if (!this.involvesDelegateModel(this.model)) { return super.updateMany(args); } @@ -631,6 +708,9 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { ); } + this.sanitizeMutationPayload(args.update); + this.sanitizeMutationPayload(args.create); + if (isDelegateModel(this.options.modelMeta, this.model)) { throw prismaClientValidationError( this.prisma, @@ -645,7 +725,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { args = clone(args); this.injectWhereHierarchy(this.model, (args as any)?.where); - this.injectSelectIncludeHierarchy(this.model, args); + await this.injectSelectIncludeHierarchy(this.model, args); if (args.create) { this.doProcessCreatePayload(this.model, args.create); } @@ -664,7 +744,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { args = clone(args); await this.injectUpdateHierarchy(db, model, args); - this.injectSelectIncludeHierarchy(model, args); + await this.injectSelectIncludeHierarchy(model, args); if (this.options.logPrismaQuery) { this.logger.info(`[delegate] \`update\` ${this.getModelName(model)}: ${formatObject(args)}`); @@ -858,7 +938,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { } return this.queryUtils.transaction(this.prisma, async (tx) => { - const selectInclude = this.buildSelectIncludeHierarchy(this.model, args); + const selectInclude = await this.buildSelectIncludeHierarchy(this.model, args); // make sure id fields are selected const idFields = this.getIdFields(this.model); @@ -910,6 +990,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { private async doDelete(db: CrudContract, model: string, args: any): Promise { this.injectWhereHierarchy(model, args.where); + await this.injectSelectIncludeHierarchy(model, args); if (this.options.logPrismaQuery) { this.logger.info(`[delegate] \`delete\` ${this.getModelName(model)}: ${formatObject(args)}`); @@ -1158,11 +1239,11 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { const base = this.getBaseModel(model); if (base) { - // merge base fields + // fully merge base fields const baseRelationName = this.makeAuxRelationName(base); const baseData = entity[baseRelationName]; if (baseData && typeof baseData === 'object') { - const baseAssembled = this.assembleUp(base.name, baseData); + const baseAssembled = this.assembleHierarchy(base.name, baseData); Object.assign(result, baseAssembled); } } @@ -1209,14 +1290,14 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { const modelInfo = getModelInfo(this.options.modelMeta, model, true); if (modelInfo.discriminator) { - // model is a delegate, merge sub model fields + // model is a delegate, fully merge concrete model fields const subModelName = entity[modelInfo.discriminator]; if (subModelName) { const subModel = getModelInfo(this.options.modelMeta, subModelName, true); const subRelationName = this.makeAuxRelationName(subModel); const subData = entity[subRelationName]; if (subData && typeof subData === 'object') { - const subAssembled = this.assembleDown(subModel.name, subData); + const subAssembled = this.assembleHierarchy(subModel.name, subData); Object.assign(result, subAssembled); } } diff --git a/packages/runtime/src/enhancements/node/omit.ts b/packages/runtime/src/enhancements/node/omit.ts index 18c81cc18..60e0251ac 100644 --- a/packages/runtime/src/enhancements/node/omit.ts +++ b/packages/runtime/src/enhancements/node/omit.ts @@ -5,6 +5,7 @@ import { enumerate, getModelFields, resolveField } from '../../cross'; import { DbClientContract } from '../../types'; import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; +import { QueryUtils } from './query-utils'; /** * Gets an enhanced Prisma client that supports `@omit` attribute. @@ -21,8 +22,11 @@ export function withOmit(prisma: DbClient, options: Int } class OmitHandler extends DefaultPrismaProxyHandler { + private queryUtils: QueryUtils; + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { super(prisma, model, options); + this.queryUtils = new QueryUtils(prisma, options); } // base override @@ -67,8 +71,10 @@ class OmitHandler extends DefaultPrismaProxyHandler { } private async doPostProcess(entityData: any, model: string) { + const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData); + for (const field of getModelFields(entityData)) { - const fieldInfo = await resolveField(this.options.modelMeta, model, field); + const fieldInfo = await resolveField(this.options.modelMeta, realModel, field); if (!fieldInfo) { continue; } diff --git a/packages/runtime/src/enhancements/node/policy/handler.ts b/packages/runtime/src/enhancements/node/policy/handler.ts index 999a7c9d9..91ab24c07 100644 --- a/packages/runtime/src/enhancements/node/policy/handler.ts +++ b/packages/runtime/src/enhancements/node/policy/handler.ts @@ -12,6 +12,7 @@ import { NestedWriteVisitorContext, enumerate, getIdFields, + getModelInfo, requireField, resolveField, type FieldInfo, @@ -435,17 +436,16 @@ export class PolicyProxyHandler implements Pr args = this.policyUtils.safeClone(args); - // go through create items, statically check input to determine if post-create - // check is needed, and also validate zod schema - const needPostCreateCheck = this.validateCreateInput(args); + // `createManyAndReturn` may need to be converted to regular `create`s + const shouldConvertToCreate = this.preprocessCreateManyPayload(args); - if (!needPostCreateCheck) { - // direct create + if (!shouldConvertToCreate) { + // direct `createMany` return this.modelClient.createMany(args); } else { // create entities in a transaction with post-create checks return this.queryUtils.transaction(this.prisma, async (tx) => { - const { result, postWriteChecks } = await this.doCreateMany(this.model, args, tx); + const { result, postWriteChecks } = await this.doCreateMany(this.model, args, tx, 'createMany'); // post-create check await this.runPostWriteChecks(postWriteChecks, tx); return { count: result.length }; @@ -472,14 +472,13 @@ export class PolicyProxyHandler implements Pr const origArgs = args; args = this.policyUtils.safeClone(args); - // go through create items, statically check input to determine if post-create - // check is needed, and also validate zod schema - const needPostCreateCheck = this.validateCreateInput(args); + // `createManyAndReturn` may need to be converted to regular `create`s + const shouldConvertToCreate = this.preprocessCreateManyPayload(args); let result: { result: unknown; error?: Error }[]; - if (!needPostCreateCheck) { - // direct create + if (!shouldConvertToCreate) { + // direct `createManyAndReturn` const created = await this.modelClient.createManyAndReturn(args); // process read-back @@ -489,7 +488,13 @@ export class PolicyProxyHandler implements Pr } else { // create entities in a transaction with post-create checks result = await this.queryUtils.transaction(this.prisma, async (tx) => { - const { result: created, postWriteChecks } = await this.doCreateMany(this.model, args, tx); + const { result: created, postWriteChecks } = await this.doCreateMany( + this.model, + args, + tx, + 'createManyAndReturn' + ); + // post-create check await this.runPostWriteChecks(postWriteChecks, tx); @@ -510,6 +515,46 @@ export class PolicyProxyHandler implements Pr }); } + /** + * Preprocess the payload of `createMany` and `createManyAndReturn` and update in place if needed. + * @returns `true` if the operation should be converted to regular `create`s; false otherwise. + */ + private preprocessCreateManyPayload(args: { data: any; select?: any; skipDuplicates?: boolean }) { + if (!args) { + return false; + } + + // if post-create check is needed + const needPostCreateCheck = this.validateCreateInput(args); + + // if the payload has any relation fields. Note that other enhancements (`withDefaultInAuth` for now) + // can introduce relation fields into the payload + let hasRelationFields = false; + if (args.data) { + hasRelationFields = this.hasRelationFieldsInPayload(this.model, args.data); + } + + return needPostCreateCheck || hasRelationFields; + } + + private hasRelationFieldsInPayload(model: string, payload: any) { + const modelInfo = getModelInfo(this.modelMeta, model); + if (!modelInfo) { + return false; + } + + for (const item of enumerate(payload)) { + for (const field of Object.keys(item)) { + const fieldInfo = resolveField(this.modelMeta, model, field); + if (fieldInfo?.isDataModel) { + return true; + } + } + } + + return false; + } + private validateCreateInput(args: { data: any; skipDuplicates?: boolean | undefined }) { let needPostCreateCheck = false; for (const item of enumerate(args.data)) { @@ -537,7 +582,12 @@ export class PolicyProxyHandler implements Pr return needPostCreateCheck; } - private async doCreateMany(model: string, args: { data: any; skipDuplicates?: boolean }, db: CrudContract) { + private async doCreateMany( + model: string, + args: { data: any; skipDuplicates?: boolean }, + db: CrudContract, + action: 'createMany' | 'createManyAndReturn' + ) { // We can't call the native "createMany" because we can't get back what was created // for post-create checks. Instead, do a "create" for each item and collect the results. @@ -553,7 +603,7 @@ export class PolicyProxyHandler implements Pr } if (this.shouldLogQuery) { - this.logger.info(`[policy] \`create\` for \`createMany\` ${model}: ${formatObject(item)}`); + this.logger.info(`[policy] \`create\` for \`${action}\` ${model}: ${formatObject(item)}`); } return await db[model].create({ select: this.policyUtils.makeIdSelection(model), data: item }); }) diff --git a/packages/runtime/src/enhancements/node/policy/index.ts b/packages/runtime/src/enhancements/node/policy/index.ts index 66834a802..d5523e31b 100644 --- a/packages/runtime/src/enhancements/node/policy/index.ts +++ b/packages/runtime/src/enhancements/node/policy/index.ts @@ -7,6 +7,7 @@ import type { InternalEnhancementOptions } from '../create-enhancement'; import { Logger } from '../logger'; import { makeProxy } from '../proxy'; import { PolicyProxyHandler } from './handler'; +import { PolicyUtil } from './policy-utils'; /** * Gets an enhanced Prisma client with access policy check. @@ -60,3 +61,20 @@ export function withPolicy( options?.errorTransformer ); } + +/** + * Function for processing a payload for including a relation field in a query. + * @param model The relation's model name + * @param payload The payload to process + */ +export async function policyProcessIncludeRelationPayload( + prisma: DbClientContract, + model: string, + payload: unknown, + options: InternalEnhancementOptions, + context: EnhancementContext | undefined +) { + const utils = new PolicyUtil(prisma, options, context); + await utils.injectForRead(prisma, model, payload); + await utils.injectReadCheckSelect(model, payload); +} diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index ab4ed8fc2..71985b30f 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -1098,6 +1098,9 @@ export class PolicyUtil extends QueryUtils { } const result = await db[model].findFirst(readArgs); if (!result) { + if (this.shouldLogQuery) { + this.logger.info(`[policy] cannot read back ${model}`); + } return { error, result: undefined }; } @@ -1381,7 +1384,10 @@ export class PolicyUtil extends QueryUtils { // preserve the original data as it may be needed for checking field-level readability, // while the "data" will be manipulated during traversal (deleting unreadable fields) const origData = this.safeClone(data); - return this.doPostProcessForRead(data, model, origData, queryArgs, this.hasFieldLevelPolicy(model)); + + // use the concrete model if the data is a polymorphic entity + const realModel = this.getDelegateConcreteModel(model, data); + return this.doPostProcessForRead(data, realModel, origData, queryArgs, this.hasFieldLevelPolicy(realModel)); } private doPostProcessForRead( diff --git a/packages/runtime/src/enhancements/node/proxy.ts b/packages/runtime/src/enhancements/node/proxy.ts index 3802e2390..ae4105301 100644 --- a/packages/runtime/src/enhancements/node/proxy.ts +++ b/packages/runtime/src/enhancements/node/proxy.ts @@ -69,7 +69,7 @@ export class DefaultPrismaProxyHandler implements PrismaProxyHandler { protected readonly options: InternalEnhancementOptions ) {} - protected withFluentCall(method: keyof PrismaProxyHandler, args: any, postProcess = true): Promise { + protected withFluentCall(method: PrismaProxyActions, args: any, postProcess = true): Promise { args = args ? clone(args) : {}; const promise = createFluentPromise( async () => { @@ -84,7 +84,7 @@ export class DefaultPrismaProxyHandler implements PrismaProxyHandler { return promise; } - protected deferred(method: keyof PrismaProxyHandler, args: any, postProcess = true) { + protected deferred(method: PrismaProxyActions, args: any, postProcess = true) { return createDeferredPromise(async () => { args = await this.preprocessArgs(method, args); const r = await this.prisma[this.model][method](args); diff --git a/packages/runtime/src/enhancements/node/query-utils.ts b/packages/runtime/src/enhancements/node/query-utils.ts index 5d23c6d99..00d430696 100644 --- a/packages/runtime/src/enhancements/node/query-utils.ts +++ b/packages/runtime/src/enhancements/node/query-utils.ts @@ -214,4 +214,22 @@ export class QueryUtils { safeClone(value: unknown): any { return value ? clone(value) : value === undefined || value === null ? {} : value; } + + getDelegateConcreteModel(model: string, data: any) { + if (!data || typeof data !== 'object') { + return model; + } + + const modelInfo = getModelInfo(this.options.modelMeta, model); + if (modelInfo?.discriminator) { + // model has a discriminator so it can be a polymorphic base, + // need to find the concrete model + const concreteModelName = data[modelInfo.discriminator]; + if (concreteModelName) { + return concreteModelName; + } + } + + return model; + } } diff --git a/packages/schema/package.json b/packages/schema/package.json index 5d71d44a1..31de9b863 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.5.1", + "version": "2.5.2", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/language-server/zmodel-scope.ts b/packages/schema/src/language-server/zmodel-scope.ts index e8e8880b5..cde2d4b5a 100644 --- a/packages/schema/src/language-server/zmodel-scope.ts +++ b/packages/schema/src/language-server/zmodel-scope.ts @@ -204,7 +204,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { private createScopeForContainingModel(node: AstNode, globalScope: Scope) { const model = getContainerOfType(node, isDataModel); if (model) { - return this.createScopeForNodes(model.fields, globalScope); + return this.createScopeForModel(model, globalScope); } else { return EMPTY_SCOPE; } diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index fecf69ced..53663edbf 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -1,4 +1,5 @@ import { + PluginError, PluginGlobalOptions, PluginOptions, RUNTIME_PACKAGE, @@ -54,6 +55,17 @@ export class ZodSchemaGenerator { ensureEmptyDir(output); Transformer.setOutputPath(output); + // options validation + if ( + this.options.mode && + (typeof this.options.mode !== 'string' || !['strip', 'strict', 'passthrough'].includes(this.options.mode)) + ) { + throw new PluginError( + name, + `Invalid mode option: "${this.options.mode}". Must be one of 'strip', 'strict', or 'passthrough'.` + ); + } + // calculate the models to be excluded const excludeModels = this.getExcludedModels(); @@ -322,7 +334,19 @@ export class ZodSchemaGenerator { writer.writeLine(`${field.name}: ${makeFieldSchema(field)},`); }); }); - writer.writeLine(');'); + + switch (this.options.mode) { + case 'strip': + // zod strips by default + writer.writeLine(')'); + break; + case 'passthrough': + writer.writeLine(').passthrough();'); + break; + default: + writer.writeLine(').strict();'); + break; + } // relation fields @@ -370,10 +394,10 @@ export function ${refineFuncName}(schema: z.ZodType isDiscriminatorField(field)); + const delegateDiscriminatorFields = model.fields.filter((field) => isDiscriminatorField(field)); const omitDiscriminators = - delegateFields.length > 0 - ? `.omit({ ${delegateFields.map((f) => `${f.name}: true`).join(', ')} })` + delegateDiscriminatorFields.length > 0 + ? `.omit({ ${delegateDiscriminatorFields.map((f) => `${f.name}: true`).join(', ')} })` : ''; //////////////////////////////////////////////// @@ -463,7 +487,7 @@ export const ${upperCaseFirst(model.name)}PrismaCreateSchema = ${prismaCreateSch }) .join(',\n')} })`; - prismaUpdateSchema = this.makePartial(prismaUpdateSchema); + prismaUpdateSchema = this.makePassthrough(this.makePartial(prismaUpdateSchema)); writer.writeLine( ` /** @@ -485,9 +509,11 @@ export const ${upperCaseFirst(model.name)}PrismaUpdateSchema = ${prismaUpdateSch // mark fields with default as optional if (fieldsWithDefault.length > 0) { + // delegate discriminator fields are omitted from the base schema, so we need + // to take care not to make them partial otherwise the schema won't compile createSchema = this.makePartial( createSchema, - fieldsWithDefault.map((f) => f.name) + fieldsWithDefault.filter((f) => !delegateDiscriminatorFields.includes(f)).map((f) => f.name) ); } diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index ca714f1ad..b39a205d7 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -291,7 +291,7 @@ export default class Transformer { prepareObjectSchema(zodObjectSchemaFields: string[], options: PluginOptions) { const objectSchema = `${this.generateExportObjectSchemaStatement( - this.addFinalWrappers({ zodStringFields: zodObjectSchemaFields }) + this.wrapWithZodObject(zodObjectSchemaFields, options.mode as string) )}\n`; const prismaImportStatement = this.generateImportPrismaStatement(options); @@ -314,12 +314,6 @@ export default class Transformer { export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; } - addFinalWrappers({ zodStringFields }: { zodStringFields: string[] }) { - const fields = [...zodStringFields]; - - return this.wrapWithZodObject(fields) + '.strict()'; - } - generateImportPrismaStatement(options: PluginOptions) { const prismaClientImportPath = computePrismaClientImport( path.resolve(Transformer.outputPath, './objects'), @@ -408,7 +402,7 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; return wrapped; } - wrapWithZodObject(zodStringFields: string | string[]) { + wrapWithZodObject(zodStringFields: string | string[], mode = 'strict') { let wrapped = ''; wrapped += 'z.object({'; @@ -416,6 +410,18 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; wrapped += ' ' + zodStringFields; wrapped += '\n'; wrapped += '})'; + + switch (mode) { + case 'strip': + // zod strips by default + break; + case 'passthrough': + wrapped += '.passthrough()'; + break; + default: + wrapped += '.strict()'; + break; + } return wrapped; } @@ -465,6 +471,7 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; ]; let codeBody = ''; const operations: [string, string][] = []; + const mode = (options.mode as string) ?? 'strict'; // OrderByWithRelationInput's name is different when "fullTextSearch" is enabled const orderByWithRelationInput = this.inputObjectTypes @@ -477,7 +484,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; imports.push( `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` ); - codeBody += `findUnique: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema`; + codeBody += `findUnique: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['findUnique', origModelName]); } @@ -488,7 +496,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'`, `import { ${modelName}ScalarFieldEnumSchema } from '../enums/${modelName}ScalarFieldEnum.schema'` ); - codeBody += `findFirst: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional() }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional()`; + codeBody += `findFirst: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['findFirst', origModelName]); } @@ -499,7 +508,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'`, `import { ${modelName}ScalarFieldEnumSchema } from '../enums/${modelName}ScalarFieldEnum.schema'` ); - codeBody += `findMany: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional() }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional()`; + codeBody += `findMany: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['findMany', origModelName]); } @@ -515,7 +525,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; const dataSchema = generateUnchecked ? `z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema])` : `${modelName}CreateInputObjectSchema`; - codeBody += `create: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema} }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema}`; + codeBody += `create: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['create', origModelName]); } @@ -523,7 +534,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; imports.push( `import { ${modelName}CreateManyInputObjectSchema } from '../objects/${modelName}CreateManyInput.schema'` ); - codeBody += `createMany: z.object({ data: z.union([${modelName}CreateManyInputObjectSchema, z.array(${modelName}CreateManyInputObjectSchema)]), skipDuplicates: z.boolean().optional() }),`; + const fields = `data: z.union([${modelName}CreateManyInputObjectSchema, z.array(${modelName}CreateManyInputObjectSchema)]), skipDuplicates: z.boolean().optional()`; + codeBody += `createMany: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['createMany', origModelName]); } @@ -531,7 +543,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; imports.push( `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` ); - codeBody += `'delete': z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema`; + codeBody += `'delete': ${this.wrapWithZodObject(fields, mode)},`; operations.push(['delete', origModelName]); } @@ -539,7 +552,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; imports.push( `import { ${modelName}WhereInputObjectSchema } from '../objects/${modelName}WhereInput.schema'` ); - codeBody += `deleteMany: z.object({ where: ${modelName}WhereInputObjectSchema.optional() }),`; + const fields = `where: ${modelName}WhereInputObjectSchema.optional()`; + codeBody += `deleteMany: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['deleteMany', origModelName]); } @@ -556,7 +570,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; const dataSchema = generateUnchecked ? `z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema])` : `${modelName}UpdateInputObjectSchema`; - codeBody += `update: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema}, where: ${modelName}WhereUniqueInputObjectSchema }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema}, where: ${modelName}WhereUniqueInputObjectSchema`; + codeBody += `update: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['update', origModelName]); } @@ -573,7 +588,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; const dataSchema = generateUnchecked ? `z.union([${modelName}UpdateManyMutationInputObjectSchema, ${modelName}UncheckedUpdateManyInputObjectSchema])` : `${modelName}UpdateManyMutationInputObjectSchema`; - codeBody += `updateMany: z.object({ data: ${dataSchema}, where: ${modelName}WhereInputObjectSchema.optional() }),`; + const fields = `data: ${dataSchema}, where: ${modelName}WhereInputObjectSchema.optional()`; + codeBody += `updateMany: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['updateMany', origModelName]); } @@ -595,7 +611,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; const updateSchema = generateUnchecked ? `z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema])` : `${modelName}UpdateInputObjectSchema`; - codeBody += `upsert: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema, create: ${createSchema}, update: ${updateSchema} }),`; + const fields = `${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema, create: ${createSchema}, update: ${updateSchema}`; + codeBody += `upsert: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['upsert', origModelName]); } @@ -641,9 +658,10 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` ); - codeBody += `aggregate: z.object({ where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), ${aggregateOperations.join( + const fields = `where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), ${aggregateOperations.join( ', ' - )} }),`; + )}`; + codeBody += `aggregate: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['aggregate', modelName]); } @@ -654,9 +672,10 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; `import { ${modelName}ScalarWhereWithAggregatesInputObjectSchema } from '../objects/${modelName}ScalarWhereWithAggregatesInput.schema'`, `import { ${modelName}ScalarFieldEnumSchema } from '../enums/${modelName}ScalarFieldEnum.schema'` ); - codeBody += `groupBy: z.object({ where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${modelName}OrderByWithAggregationInputObjectSchema, ${modelName}OrderByWithAggregationInputObjectSchema.array()]).optional(), having: ${modelName}ScalarWhereWithAggregatesInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), by: z.array(${modelName}ScalarFieldEnumSchema), ${aggregateOperations.join( + const fields = `where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${modelName}OrderByWithAggregationInputObjectSchema, ${modelName}OrderByWithAggregationInputObjectSchema.array()]).optional(), having: ${modelName}ScalarWhereWithAggregatesInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), by: z.array(${modelName}ScalarFieldEnumSchema), ${aggregateOperations.join( ', ' - )} }),`; + )}`; + codeBody += `groupBy: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['groupBy', origModelName]); } @@ -671,7 +690,8 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; `import { ${modelName}CountAggregateInputObjectSchema } from '../objects/${modelName}CountAggregateInput.schema'` ); - codeBody += `count: z.object({ where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional(), select: z.union([ z.literal(true), ${modelName}CountAggregateInputObjectSchema ]).optional() })`; + const fields = `where: ${modelName}WhereInputObjectSchema.optional(), orderBy: z.union([${orderByWithRelationInput}ObjectSchema, ${orderByWithRelationInput}ObjectSchema.array()]).optional(), cursor: ${modelName}WhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), distinct: z.array(${modelName}ScalarFieldEnumSchema).optional(), select: z.union([ z.literal(true), ${modelName}CountAggregateInputObjectSchema ]).optional()`; + codeBody += `count: ${this.wrapWithZodObject(fields, mode)},`; operations.push(['count', origModelName]); } diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index c711e7404..a436ea4a8 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -97,7 +97,7 @@ function cuid(): String { } @@@expressionContext([DefaultValue]) /** - * Generates an indentifier based on the nanoid spec. + * Generates an identifier based on the nanoid spec. */ function nanoid(length: Int?): String { } @@@expressionContext([DefaultValue]) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3d1cf97d5..1e2886fee 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.5.1", + "version": "2.5.2", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 3c2854f46..86a78cf1b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.5.1", + "version": "2.5.2", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 6a1b1a039..5eff96939 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.5.1", + "version": "2.5.2", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 552ed617a..a4c516ce6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -433,6 +433,9 @@ importers: tiny-invariant: specifier: ^1.3.1 version: 1.3.3 + traverse: + specifier: ^0.6.10 + version: 0.6.10 ts-pattern: specifier: ^4.3.0 version: 4.3.0 @@ -464,6 +467,9 @@ importers: '@types/semver': specifier: ^7.3.13 version: 7.5.8 + '@types/traverse': + specifier: ^0.6.37 + version: 0.6.37 '@types/uuid': specifier: ^8.3.4 version: 8.3.4 @@ -3074,6 +3080,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/traverse@0.6.37': + resolution: {integrity: sha512-c90MVeDiUI1FhOZ6rLQ3kDWr50YE8+paDpM+5zbHjbmsqEp2DlMYkqnZnwbK9oI+NvDe8yRajup4jFwnVX6xsA==} + '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -3519,6 +3528,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + arrify@1.0.1: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} @@ -4162,6 +4175,18 @@ packages: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -4450,6 +4475,10 @@ packages: error-stack-parser-es@0.1.4: resolution: {integrity: sha512-l0uy0kAoo6toCgVOYaAayqtPa2a1L15efxUMEnQebKwLQX2X0OpS6wMMQdc4juJXmxd9i40DuaUHq+mjIya9TQ==} + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + es-define-property@1.0.0: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} @@ -4461,6 +4490,18 @@ packages: es-get-iterator@1.1.3: resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + esbuild-android-64@0.15.18: resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} engines: {node: '>=12'} @@ -4917,6 +4958,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} @@ -4955,6 +5000,10 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.7.5: resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} @@ -5013,6 +5062,10 @@ packages: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -5307,6 +5360,10 @@ packages: resolution: {integrity: sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==} engines: {node: '>= 0.4'} + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} @@ -5364,6 +5421,10 @@ packages: is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -5431,6 +5492,10 @@ packages: resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} engines: {node: '>= 0.4'} + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -5439,6 +5504,9 @@ packages: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + is-weakset@2.0.3: resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} engines: {node: '>= 0.4'} @@ -7240,6 +7308,10 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -7249,6 +7321,10 @@ packages: safe-json-stringify@1.2.0: resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + safe-regex2@3.1.0: resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} @@ -7518,6 +7594,17 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + string_decoder@0.10.31: resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} @@ -7793,6 +7880,10 @@ packages: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} + traverse@0.6.10: + resolution: {integrity: sha512-hN4uFRxbK+PX56DxYiGHsTn2dME3TVr9vbNqlQGcGcPhJAn+tdP126iA+TArMpI4YSgnTkMWyoLl5bf81Hi5TA==} + engines: {node: '>= 0.4'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -7929,9 +8020,29 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + typed-rest-client@1.8.11: resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} + typedarray.prototype.slice@1.0.3: + resolution: {integrity: sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==} + engines: {node: '>= 0.4'} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -7965,6 +8076,9 @@ packages: ultrahtml@1.5.3: resolution: {integrity: sha512-GykOvZwgDWZlTQMtp5jrD4BVL+gNn2NVlVafjcFUJ7taY20tqYdwdoWBFy6GBJsNTZe1GkGPkSl5knQAjtgceg==} + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -11247,6 +11361,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/traverse@0.6.37': {} + '@types/uuid@8.3.4': {} '@types/vscode@1.90.0': {} @@ -11813,6 +11929,17 @@ snapshots: array-union@2.1.0: {} + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + arrify@1.0.1: {} asap@2.0.6: {} @@ -12571,6 +12698,24 @@ snapshots: whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + date-fns@2.30.0: dependencies: '@babel/runtime': 7.24.7 @@ -12808,6 +12953,55 @@ snapshots: error-stack-parser-es@0.1.4: {} + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + es-define-property@1.0.0: dependencies: get-intrinsic: 1.2.4 @@ -12826,6 +13020,22 @@ snapshots: isarray: 2.0.5 stop-iteration-iterator: 1.0.0 + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + esbuild-android-64@0.15.18: optional: true @@ -13421,6 +13631,13 @@ snapshots: function-bind@1.1.2: {} + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + functions-have-names@1.2.3: {} gauge@3.0.2: @@ -13457,6 +13674,12 @@ snapshots: get-stream@8.0.1: {} + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + get-tsconfig@4.7.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -13534,6 +13757,11 @@ snapshots: dependencies: type-fest: 0.20.2 + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -13862,6 +14090,10 @@ snapshots: dependencies: hasown: 2.0.2 + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + is-date-object@1.0.5: dependencies: has-tostringtag: 1.0.2 @@ -13899,6 +14131,8 @@ snapshots: is-module@1.0.0: {} + is-negative-zero@2.0.3: {} + is-number-object@1.0.7: dependencies: has-tostringtag: 1.0.2 @@ -13954,10 +14188,18 @@ snapshots: dependencies: has-symbols: 1.0.3 + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + is-unicode-supported@0.1.0: {} is-weakmap@2.0.2: {} + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + is-weakset@2.0.3: dependencies: call-bind: 1.0.7 @@ -16221,12 +16463,25 @@ snapshots: dependencies: mri: 1.2.0 + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} safe-json-stringify@1.2.0: {} + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + safe-regex2@3.1.0: dependencies: ret: 0.4.3 @@ -16506,6 +16761,25 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + string_decoder@0.10.31: {} string_decoder@1.1.1: @@ -16803,6 +17077,12 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.6.10: + dependencies: + gopd: 1.0.1 + typedarray.prototype.slice: 1.0.3 + which-typed-array: 1.1.15 + tree-kill@1.2.2: {} ts-api-utils@1.3.0(typescript@5.5.2): @@ -16933,12 +17213,53 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + typed-rest-client@1.8.11: dependencies: qs: 6.12.1 tunnel: 0.0.6 underscore: 1.13.6 + typedarray.prototype.slice@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + typed-array-buffer: 1.0.2 + typed-array-byte-offset: 1.0.2 + typedarray@0.0.6: {} typescript@5.5.2: {} @@ -16959,6 +17280,13 @@ snapshots: ultrahtml@1.5.3: {} + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + uncrypto@0.1.3: {} unctx@2.3.1: diff --git a/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts b/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts index 4d71f26dc..59a3f68c0 100644 --- a/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts +++ b/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts @@ -1361,4 +1361,50 @@ describe('Polymorphism Test', () => { ], }); }); + + it('merges hierarchy correctly', async () => { + const { enhance } = await loadSchema( + ` + model Asset { + id Int @id @default(autoincrement()) + type String + viewCount Int + comments Comment[] + @@delegate(type) + } + + model Post extends Asset { + title String + } + + model Comment { + id Int @id @default(autoincrement()) + type String + asset Asset @relation(fields: [assetId], references: [id]) + assetId Int + moderated Boolean + @@delegate(type) + } + + model TextComment extends Comment { + text String + } + `, + { enhancements: ['delegate'] } + ); + + const db = enhance(); + const post = await db.post.create({ data: { title: 'Post1', viewCount: 1 } }); + const comment = await db.textComment.create({ + data: { text: 'Comment1', moderated: true, asset: { connect: { id: post.id } } }, + }); + + // delegate include delegate + let r = await db.asset.findFirst({ include: { comments: true } }); + expect(r).toMatchObject({ viewCount: 1, comments: [comment] }); + + // concrete include delegate + r = await db.post.findFirst({ include: { comments: true } }); + expect(r).toMatchObject({ ...post, comments: [comment] }); + }); }); diff --git a/tests/integration/tests/enhancements/with-delegate/omit-interaction.test.ts b/tests/integration/tests/enhancements/with-delegate/omit-interaction.test.ts new file mode 100644 index 000000000..7b4be2309 --- /dev/null +++ b/tests/integration/tests/enhancements/with-delegate/omit-interaction.test.ts @@ -0,0 +1,91 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('Polymorphic @omit', () => { + const model = ` + model User { + id Int @id @default(autoincrement()) + assets Asset[] + + @@allow('all', true) + } + + model Asset { + id Int @id @default(autoincrement()) + type String + foo String @omit + user User? @relation(fields: [userId], references: [id]) + userId Int? @unique + + @@delegate(type) + @@allow('all', true) + } + + model Post extends Asset { + title String + bar String @omit + } + `; + + it('omits when queried via a concrete model', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + const post = await db.post.create({ data: { foo: 'foo', bar: 'bar', title: 'Post1' } }); + expect(post.foo).toBeUndefined(); + expect(post.bar).toBeUndefined(); + + const foundPost = await db.post.findUnique({ where: { id: post.id } }); + expect(foundPost.foo).toBeUndefined(); + expect(foundPost.bar).toBeUndefined(); + }); + + it('omits when queried via a base model', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + const post = await db.post.create({ data: { foo: 'foo', bar: 'bar', title: 'Post1' } }); + expect(post.foo).toBeUndefined(); + expect(post.bar).toBeUndefined(); + + const foundAsset = await db.asset.findUnique({ where: { id: post.id } }); + expect(foundAsset.foo).toBeUndefined(); + expect(foundAsset.bar).toBeUndefined(); + expect(foundAsset.title).toBeTruthy(); + }); + + it('omits when discriminator is not selected', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + const post = await db.post.create({ + data: { foo: 'foo', bar: 'bar', title: 'Post1' }, + }); + expect(post.foo).toBeUndefined(); + expect(post.bar).toBeUndefined(); + + const foundAsset = await db.asset.findUnique({ + where: { id: post.id }, + select: { id: true, foo: true }, + }); + console.log(foundAsset); + expect(foundAsset.foo).toBeUndefined(); + expect(foundAsset.bar).toBeUndefined(); + }); + + it('omits when queried in a nested context', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + const user = await db.user.create({ data: {} }); + const post = await db.post.create({ + data: { foo: 'foo', bar: 'bar', title: 'Post1', user: { connect: { id: user.id } } }, + }); + expect(post.foo).toBeUndefined(); + expect(post.bar).toBeUndefined(); + + const foundUser = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(foundUser.assets[0].foo).toBeUndefined(); + expect(foundUser.assets[0].bar).toBeUndefined(); + expect(foundUser.assets[0].title).toBeTruthy(); + }); +}); diff --git a/tests/integration/tests/enhancements/with-delegate/password-interaction.test.ts b/tests/integration/tests/enhancements/with-delegate/password-interaction.test.ts new file mode 100644 index 000000000..3b05a3937 --- /dev/null +++ b/tests/integration/tests/enhancements/with-delegate/password-interaction.test.ts @@ -0,0 +1,50 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { compareSync } from 'bcryptjs'; + +describe('Polymorphic @omit', () => { + const model = ` + model User { + id Int @id @default(autoincrement()) + posts Post[] + + @@allow('all', true) + } + + model Asset { + id Int @id @default(autoincrement()) + type String + assetPassword String @password + + @@delegate(type) + @@allow('all', true) + } + + model Post extends Asset { + title String + postPassword String @password + user User? @relation(fields: [userId], references: [id]) + userId Int? @unique + } + `; + + it('hashes when created directly', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + const post = await db.post.create({ data: { title: 'Post1', assetPassword: 'asset', postPassword: 'post' } }); + expect(compareSync('asset', post.assetPassword)).toBeTruthy(); + expect(compareSync('post', post.postPassword)).toBeTruthy(); + }); + + it('hashes when created nested', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + const user = await db.user.create({ + data: { posts: { create: { title: 'Post1', assetPassword: 'asset', postPassword: 'post' } } }, + include: { posts: true }, + }); + expect(compareSync('asset', user.posts[0].assetPassword)).toBeTruthy(); + expect(compareSync('post', user.posts[0].postPassword)).toBeTruthy(); + }); +}); diff --git a/tests/integration/tests/enhancements/with-delegate/policy-interaction.test.ts b/tests/integration/tests/enhancements/with-delegate/policy-interaction.test.ts index f84e3c603..ff791beb1 100644 --- a/tests/integration/tests/enhancements/with-delegate/policy-interaction.test.ts +++ b/tests/integration/tests/enhancements/with-delegate/policy-interaction.test.ts @@ -268,4 +268,260 @@ describe('Polymorphic Policy Test', () => { user1Db.ratedPost.update({ where: { id: post1.id }, data: { comments: { connect: { id: comment1.id } } } }) ).toResolveTruthy(); }); + + it('respects base model policies when queried from a sub', async () => { + const { enhance, prisma } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + assets Asset[] + @@allow('all', true) + } + + model Asset { + id Int @id @default(autoincrement()) + deleted Boolean @default(false) + user User @relation(fields: [userId], references: [id]) + userId Int + type String + @@delegate(type) + @@allow('all', true) + @@deny('read', deleted) + } + + model Post extends Asset { + title String + } + ` + ); + + const db = enhance(); + const user = await db.user.create({ data: { id: 1 } }); + const post = await db.post.create({ data: { id: 1, title: 'Post1', userId: user.id } }); + + await expect(db.post.findUnique({ where: { id: post.id } })).toResolveTruthy(); + await expect(db.asset.findUnique({ where: { id: post.id } })).toResolveTruthy(); + let withAssets = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(withAssets.assets).toHaveLength(1); + + await prisma.asset.update({ where: { id: post.id }, data: { deleted: true } }); + await expect(db.post.findUnique({ where: { id: post.id } })).toResolveFalsy(); + await expect(db.asset.findUnique({ where: { id: post.id } })).toResolveFalsy(); + withAssets = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(withAssets.assets).toHaveLength(0); + + // unable to read back + await expect( + db.post.create({ data: { title: 'Post2', deleted: true, userId: user.id } }) + ).toBeRejectedByPolicy(); + // actually created + await expect(prisma.post.count()).resolves.toBe(2); + + // unable to read back + await expect(db.post.update({ where: { id: 2 }, data: { title: 'Post2-1' } })).toBeRejectedByPolicy(); + // actually updated + await expect(prisma.post.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ title: 'Post2-1' }); + + // unable to read back + await expect(db.post.delete({ where: { id: 2 } })).toBeRejectedByPolicy(); + // actually deleted + await expect(prisma.post.findUnique({ where: { id: 2 } })).toResolveFalsy(); + }); + + it('respects sub model policies when queried from a base: case 1', async () => { + const { enhance, prisma } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + assets Asset[] + @@allow('all', true) + } + + model Asset { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + value Int @default(0) + deleted Boolean @default(false) + type String + @@delegate(type) + @@allow('all', true) + } + + model Post extends Asset { + title String + @@deny('read', deleted) + } + ` + ); + + const db = enhance(); + const user = await db.user.create({ data: { id: 1 } }); + + // create read back + const post = await db.post.create({ data: { id: 1, title: 'Post1', userId: user.id } }); + expect(post.type).toBe('Post'); + expect(post.title).toBe('Post1'); + expect(post.value).toBe(0); + + // update read back + const updatedPost = await db.post.update({ where: { id: post.id }, data: { value: 1 } }); + expect(updatedPost.type).toBe('Post'); + expect(updatedPost.title).toBe('Post1'); + expect(updatedPost.value).toBe(1); + + // both asset and post fields are readable + const readPost = await db.post.findUnique({ where: { id: post.id } }); + expect(readPost.title).toBe('Post1'); + + const readAsset = await db.asset.findUnique({ where: { id: post.id } }); + expect(readAsset.type).toBe('Post'); + const userWithAssets = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(userWithAssets.assets[0].title).toBe('Post1'); + + await prisma.asset.update({ where: { id: post.id }, data: { deleted: true } }); + + // asset fields are readable, but not post fields + const readAsset1 = await db.asset.findUnique({ where: { id: post.id } }); + expect(readAsset1.type).toBe('Post'); + expect(readAsset1.title).toBeUndefined(); + + const userWithAssets1 = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(userWithAssets1.assets[0].type).toBe('Post'); + expect(userWithAssets1.assets[0].title).toBeUndefined(); + + // update read back + const updateRead = await db.asset.update({ where: { id: post.id }, data: { value: 2 } }); + expect(updateRead.value).toBe(2); + // cannot read back sub model + expect(updateRead.title).toBeUndefined(); + + // delete read back + const deleteRead = await db.asset.delete({ where: { id: post.id } }); + expect(deleteRead.value).toBe(2); + // cannot read back sub model + expect(deleteRead.title).toBeUndefined(); + // actually deleted + await expect(prisma.asset.findUnique({ where: { id: post.id } })).toResolveFalsy(); + await expect(prisma.post.findUnique({ where: { id: post.id } })).toResolveFalsy(); + }); + + it('respects sub model policies when queried from a base: case 2', async () => { + const { enhance, prisma } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + assets Asset[] + @@allow('all', true) + } + + model Asset { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + value Int @default(0) + type String + @@delegate(type) + @@allow('all', true) + } + + model Post extends Asset { + title String + deleted Boolean @default(false) + @@deny('read', deleted) + } + ` + ); + + const db = enhance(); + const user = await db.user.create({ data: { id: 1 } }); + + // create read back + const post = await db.post.create({ data: { id: 1, title: 'Post1', userId: user.id } }); + expect(post.type).toBe('Post'); + expect(post.title).toBe('Post1'); + expect(post.value).toBe(0); + + // update read back + const updatedPost = await db.post.update({ where: { id: post.id }, data: { value: 1 } }); + expect(updatedPost.type).toBe('Post'); + expect(updatedPost.title).toBe('Post1'); + expect(updatedPost.value).toBe(1); + + // both asset and post fields are readable + const readPost = await db.post.findUnique({ where: { id: post.id } }); + expect(readPost.title).toBe('Post1'); + + const readAsset = await db.asset.findUnique({ where: { id: post.id } }); + expect(readAsset.type).toBe('Post'); + const userWithAssets = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(userWithAssets.assets[0].title).toBe('Post1'); + + await prisma.post.update({ where: { id: post.id }, data: { deleted: true } }); + + // asset fields are readable, but not post fields + const readAsset1 = await db.asset.findUnique({ where: { id: post.id } }); + expect(readAsset1.type).toBe('Post'); + expect(readAsset1.title).toBeUndefined(); + + const userWithAssets1 = await db.user.findUnique({ where: { id: user.id }, include: { assets: true } }); + expect(userWithAssets1.assets[0].type).toBe('Post'); + expect(userWithAssets1.assets[0].title).toBeUndefined(); + + // update read back + const updateRead = await db.asset.update({ where: { id: post.id }, data: { value: 2 } }); + expect(updateRead.value).toBe(2); + // cannot read back sub model + expect(updateRead.title).toBeUndefined(); + + // delete read back + const deleteRead = await db.asset.delete({ where: { id: post.id } }); + expect(deleteRead.value).toBe(2); + // cannot read back sub model + expect(deleteRead.title).toBeUndefined(); + // actually deleted + await expect(prisma.asset.findUnique({ where: { id: post.id } })).toResolveFalsy(); + await expect(prisma.post.findUnique({ where: { id: post.id } })).toResolveFalsy(); + }); + + it('respects field-level policies', async () => { + const { enhance } = await loadSchema(` + model User { + id Int @id @default(autoincrement()) + } + + model Asset { + id Int @id @default(autoincrement()) + type String + foo String @allow('read', auth().id == 1) + + @@delegate(type) + @@allow('all', true) + } + + model Post extends Asset { + title String + bar String @deny('read', auth().id != 1) + } + `); + + const db = enhance({ id: 1 }); + const post = await db.post.create({ data: { foo: 'foo', bar: 'bar', title: 'Post1' } }); + expect(post.foo).toBeTruthy(); + expect(post.bar).toBeTruthy(); + + const foundPost = await db.post.findUnique({ where: { id: post.id } }); + expect(foundPost.foo).toBeTruthy(); + expect(foundPost.bar).toBeTruthy(); + + const db2 = enhance({ id: 2 }); + const post2 = await db2.post.create({ data: { foo: 'foo', bar: 'bar', title: 'Post2' } }); + expect(post2.title).toBeTruthy(); + expect(post2.foo).toBeUndefined(); + expect(post2.bar).toBeUndefined(); + + const foundPost2 = await db2.post.findUnique({ where: { id: post2.id } }); + expect(foundPost2.foo).toBeUndefined(); + expect(foundPost2.bar).toBeUndefined(); + }); }); diff --git a/tests/integration/tests/enhancements/with-delegate/validation.test.ts b/tests/integration/tests/enhancements/with-delegate/validation.test.ts new file mode 100644 index 000000000..8e729c337 --- /dev/null +++ b/tests/integration/tests/enhancements/with-delegate/validation.test.ts @@ -0,0 +1,26 @@ +import { loadSchema } from '@zenstackhq/testtools'; +describe('Polymorphic input validation', () => { + it('rejects aux fields in mutation', async () => { + const { enhance } = await loadSchema( + ` + model Asset { + id Int @id @default(autoincrement()) + type String + + @@delegate(type) + @@allow('all', true) + } + + model Post extends Asset { + title String + } + ` + ); + + const db = enhance(); + const asset = await db.post.create({ data: { title: 'Post1' } }); + await expect( + db.asset.update({ where: { id: asset.id }, data: { delegate_aux_post: { update: { title: 'Post2' } } } }) + ).rejects.toThrow('Auxiliary relation field "delegate_aux_post" cannot be set directly'); + }); +}); diff --git a/tests/integration/tests/enhancements/with-policy/auth.test.ts b/tests/integration/tests/enhancements/with-policy/auth.test.ts index f397fa804..02c3959d0 100644 --- a/tests/integration/tests/enhancements/with-policy/auth.test.ts +++ b/tests/integration/tests/enhancements/with-policy/auth.test.ts @@ -389,6 +389,11 @@ describe('auth() runtime test', () => { await expect(userDb.post.create({ data: { title: 'abc' } })).toResolveTruthy(); await expect(userDb.post.findMany()).resolves.toHaveLength(1); await expect(userDb.post.count({ where: { authorName: 'user1', score: 10 } })).resolves.toBe(1); + + await expect(userDb.post.createMany({ data: [{ title: 'def' }] })).resolves.toMatchObject({ count: 1 }); + const r = await userDb.post.createManyAndReturn({ data: [{ title: 'xxx' }, { title: 'yyy' }] }); + expect(r[0]).toMatchObject({ title: 'xxx', score: 10 }); + expect(r[1]).toMatchObject({ title: 'yyy', score: 10 }); }); it('Default auth() data should not override passed args', async () => { @@ -414,6 +419,12 @@ describe('auth() runtime test', () => { const userDb = enhance({ id: '1', name: userContextName }); await expect(userDb.post.create({ data: { authorName: overrideName } })).toResolveTruthy(); await expect(userDb.post.count({ where: { authorName: overrideName } })).resolves.toBe(1); + + await expect(userDb.post.createMany({ data: [{ authorName: overrideName }] })).toResolveTruthy(); + await expect(userDb.post.count({ where: { authorName: overrideName } })).resolves.toBe(2); + + const r = await userDb.post.createManyAndReturn({ data: [{ authorName: overrideName }] }); + expect(r[0]).toMatchObject({ authorName: overrideName }); }); it('Default auth() with foreign key', async () => { @@ -465,6 +476,15 @@ describe('auth() runtime test', () => { update: { title: 'post4' }, }) ).resolves.toMatchObject({ authorId: 'userId-1' }); + + // default auth effective for createMany + await expect(db.post.createMany({ data: { title: 'post5' } })).resolves.toMatchObject({ count: 1 }); + const r = await db.post.findFirst({ where: { title: 'post5' } }); + expect(r).toMatchObject({ authorId: 'userId-1' }); + + // default auth effective for createManyAndReturn + const r1 = await db.post.createManyAndReturn({ data: { title: 'post6' } }); + expect(r1[0]).toMatchObject({ authorId: 'userId-1' }); }); it('Default auth() with nested user context value', async () => { @@ -631,14 +651,23 @@ describe('auth() runtime test', () => { const db = enhance({ id: 'userId-1' }); await db.user.create({ data: { id: 'userId-1' } }); - // safe + // unsafe await db.stats.create({ data: { id: 'stats-1', viewCount: 10 } }); - await expect(db.post.create({ data: { title: 'title', statsId: 'stats-1' } })).toResolveTruthy(); + await expect(db.post.create({ data: { title: 'title1', statsId: 'stats-1' } })).toResolveTruthy(); - // unsafe await db.stats.create({ data: { id: 'stats-2', viewCount: 10 } }); + await expect(db.post.createMany({ data: [{ title: 'title2', statsId: 'stats-2' }] })).resolves.toMatchObject({ + count: 1, + }); + + await db.stats.create({ data: { id: 'stats-3', viewCount: 10 } }); + const r = await db.post.createManyAndReturn({ data: [{ title: 'title3', statsId: 'stats-3' }] }); + expect(r[0]).toMatchObject({ statsId: 'stats-3' }); + + // safe + await db.stats.create({ data: { id: 'stats-4', viewCount: 10 } }); await expect( - db.post.create({ data: { title: 'title', stats: { connect: { id: 'stats-2' } } } }) + db.post.create({ data: { title: 'title4', stats: { connect: { id: 'stats-4' } } } }) ).toResolveTruthy(); }); }); diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index 2b9d73c59..5af7f4077 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -850,4 +850,179 @@ describe('Zod plugin tests', () => { ) ).rejects.toThrow('already exists and is not a directory'); }); + + it('is strict by default', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = '@core/zod' + } + + model User { + id Int @id @default(autoincrement()) + email String @unique @email @endsWith('@zenstack.dev') + password String + @@validate(length(password, 6, 20)) + } + `, + { addPrelude: false, pushDb: false } + ); + + const schemas = zodSchemas.models; + expect( + schemas.UserSchema.safeParse({ id: 1, email: 'abc@zenstack.dev', password: 'abc123' }).success + ).toBeTruthy(); + expect( + schemas.UserSchema.safeParse({ id: 1, email: 'abc@zenstack.dev', password: 'abc123', x: 1 }).success + ).toBeFalsy(); + + expect( + schemas.UserCreateSchema.safeParse({ email: 'abc@zenstack.dev', password: 'abc123' }).success + ).toBeTruthy(); + expect( + schemas.UserCreateSchema.safeParse({ email: 'abc@zenstack.dev', password: 'abc123', x: 1 }).success + ).toBeFalsy(); + + // Prisma create/update schema should always non-strict + expect( + schemas.UserPrismaCreateSchema.safeParse({ email: 'abc@zenstack.dev', password: 'abc123', x: 1 }).data.x + ).toBe(1); + expect(schemas.UserPrismaUpdateSchema.safeParse({ x: 1 }).data.x).toBe(1); + + expect( + zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123' }, + }).success + ).toBeTruthy(); + expect( + zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123', x: 1 }, + }).success + ).toBeFalsy(); + }); + + it('works in strip mode', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = '@core/zod' + mode = 'strip' + } + + model User { + id Int @id @default(autoincrement()) + email String @unique @email @endsWith('@zenstack.dev') + password String + @@validate(length(password, 6, 20)) + } + `, + { addPrelude: false, pushDb: false } + ); + + const schemas = zodSchemas.models; + let parsed = schemas.UserSchema.safeParse({ id: 1, email: 'abc@zenstack.dev', password: 'abc123', x: 1 }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data.x).toBeUndefined(); + + parsed = schemas.UserCreateSchema.safeParse({ email: 'abc@zenstack.dev', password: 'abc123', x: 1 }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data.x).toBeUndefined(); + + parsed = zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123', x: 1 }, + }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data.data.x).toBeUndefined(); + }); + + it('works in passthrough mode', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = '@core/zod' + mode = 'passthrough' + } + + model User { + id Int @id @default(autoincrement()) + email String @unique @email @endsWith('@zenstack.dev') + password String + @@validate(length(password, 6, 20)) + } + `, + { addPrelude: false, pushDb: false } + ); + + const schemas = zodSchemas.models; + let parsed = schemas.UserSchema.safeParse({ id: 1, email: 'abc@zenstack.dev', password: 'abc123', x: 1 }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data.x).toBe(1); + + parsed = schemas.UserCreateSchema.safeParse({ email: 'abc@zenstack.dev', password: 'abc123', x: 1 }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data.x).toBe(1); + + parsed = zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123', x: 1 }, + }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data.data.x).toBe(1); + }); + + it('complains about invalid mode', async () => { + await expect( + loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = '@core/zod' + mode = 'xyz' + } + + model User { + id Int @id @default(autoincrement()) + email String @unique @email @endsWith('@zenstack.dev') + password String + @@validate(length(password, 6, 20)) + } + `, + { addPrelude: false, pushDb: false } + ) + ).rejects.toThrow(/Invalid mode/); + }); }); diff --git a/tests/regression/tests/issue-1681.test.ts b/tests/regression/tests/issue-1681.test.ts new file mode 100644 index 000000000..e3b7bd630 --- /dev/null +++ b/tests/regression/tests/issue-1681.test.ts @@ -0,0 +1,29 @@ +import { loadSchema } from '@zenstackhq/testtools'; +describe('issue 1681', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + posts Post[] + @@allow('all', true) + } + + model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int @default(auth().id) + @@allow('all', true) + } + ` + ); + + const db = enhance({ id: 1 }); + const user = await db.user.create({ data: {} }); + await expect(db.post.createMany({ data: [{ title: 'Post1' }] })).resolves.toMatchObject({ count: 1 }); + + const r = await db.post.createManyAndReturn({ data: [{ title: 'Post2' }] }); + expect(r[0].authorId).toBe(user.id); + }); +}); diff --git a/tests/regression/tests/issue-1693.test.ts b/tests/regression/tests/issue-1693.test.ts new file mode 100644 index 000000000..cf61b286c --- /dev/null +++ b/tests/regression/tests/issue-1693.test.ts @@ -0,0 +1,20 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1693', () => { + it('regression', async () => { + await loadSchema( + ` + model Animal { + id String @id @default(uuid()) + animalType String @default("") + @@delegate(animalType) + } + + model Dog extends Animal { + name String + } + `, + { fullZod: true } + ); + }); +}); diff --git a/tests/regression/tests/issue-1695.test.ts b/tests/regression/tests/issue-1695.test.ts new file mode 100644 index 000000000..c58417265 --- /dev/null +++ b/tests/regression/tests/issue-1695.test.ts @@ -0,0 +1,21 @@ +import { loadModel } from '@zenstackhq/testtools'; + +describe('issue 1695', () => { + it('regression', async () => { + await loadModel( + ` + abstract model SoftDelete { + deleted Int @default(0) @omit + } + + model MyModel extends SoftDelete { + id String @id @default(cuid()) + name String + + @@deny('update', deleted != 0 && future().deleted != 0) + @@deny('read', this.deleted != 0) + } + ` + ); + }); +}); diff --git a/tests/regression/tests/issue-1698.test.ts b/tests/regression/tests/issue-1698.test.ts new file mode 100644 index 000000000..4d6f52f54 --- /dev/null +++ b/tests/regression/tests/issue-1698.test.ts @@ -0,0 +1,74 @@ +import { loadSchema } from '@zenstackhq/testtools'; +describe('issue 1968', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model House { + id Int @id @default(autoincrement()) + doorTypeId Int + door Door @relation(fields: [doorTypeId], references: [id]) + houseType String + @@delegate(houseType) + } + + model PrivateHouse extends House { + size Int + } + + model Skyscraper extends House { + height Int + } + + model Door { + id Int @id @default(autoincrement()) + color String + doorType String + houses House[] + @@delegate(doorType) + } + + model IronDoor extends Door { + strength Int + } + + model WoodenDoor extends Door { + texture String + } + `, + { enhancements: ['delegate'] } + ); + + const db = enhance(); + const door1 = await db.ironDoor.create({ + data: { strength: 100, color: 'blue' }, + }); + console.log(door1); + + const door2 = await db.woodenDoor.create({ + data: { texture: 'pine', color: 'red' }, + }); + console.log(door2); + + const house1 = await db.privateHouse.create({ + data: { size: 5000, door: { connect: { id: door1.id } } }, + }); + console.log(house1); + + const house2 = await db.skyscraper.create({ + data: { height: 3000, door: { connect: { id: door2.id } } }, + }); + console.log(house2); + + const r1 = await db.privateHouse.findFirst({ include: { door: true } }); + console.log(r1); + expect(r1).toMatchObject({ + door: { color: 'blue', strength: 100 }, + }); + + const r2 = (await db.skyscraper.findMany({ include: { door: true } }))[0]; + console.log(r2); + expect(r2).toMatchObject({ + door: { color: 'red', texture: 'pine' }, + }); + }); +}); diff --git a/tests/regression/tests/issue-1710.test.ts b/tests/regression/tests/issue-1710.test.ts new file mode 100644 index 000000000..796c403b4 --- /dev/null +++ b/tests/regression/tests/issue-1710.test.ts @@ -0,0 +1,53 @@ +import { loadSchema } from '@zenstackhq/testtools'; +describe('issue 1710', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model Profile { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + displayName String + type String + + @@delegate(type) + @@allow('read,create', true) + } + + model User extends Profile { + email String @unique @deny('read', true) + password String @omit + role String @default('USER') @deny('read,update', true) + } + + model Organization extends Profile {} + ` + ); + + const db = enhance(); + const user = await db.user.create({ + data: { displayName: 'User1', email: 'a@b.com', password: '123' }, + }); + expect(user.email).toBeUndefined(); + expect(user.password).toBeUndefined(); + + const foundUser = await db.profile.findUnique({ where: { id: user.id } }); + expect(foundUser.email).toBeUndefined(); + expect(foundUser.password).toBeUndefined(); + + await expect( + db.profile.update({ + where: { + id: user.id, + }, + data: { + delegate_aux_user: { + update: { + role: 'ADMIN', + }, + }, + }, + }) + ).rejects.toThrow('Auxiliary relation field'); + }); +});