From e23f5c94fb4961693a0811b71368de430d2bcb16 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:10:12 -0800 Subject: [PATCH 1/2] fix(delegate): clean up generated zod schemas for delegate auxiliary fields fixes #1993 --- packages/schema/src/plugins/zod/generator.ts | 16 +++++-- .../schema/src/plugins/zod/transformer.ts | 45 +++++++++++++++++-- tests/regression/tests/issue-1993.test.ts | 45 +++++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 tests/regression/tests/issue-1993.test.ts diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index 46e6505fe..341b8cae5 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -1,3 +1,4 @@ +import { DELEGATE_AUX_RELATION_PREFIX } from '@zenstackhq/runtime'; import { ExpressionContext, PluginError, @@ -88,12 +89,18 @@ export class ZodSchemaGenerator { (o) => !excludeModels.find((e) => e === o.model) ); - // TODO: better way of filtering than string startsWith? const inputObjectTypes = prismaClientDmmf.schema.inputObjectTypes.prisma.filter( - (type) => !excludeModels.find((e) => type.name.toLowerCase().startsWith(e.toLocaleLowerCase())) + (type) => + !excludeModels.some((e) => type.name.toLowerCase().startsWith(e.toLocaleLowerCase())) && + // exclude delegate aux related types + !type.name.toLowerCase().includes(DELEGATE_AUX_RELATION_PREFIX) ); + const outputObjectTypes = prismaClientDmmf.schema.outputObjectTypes.prisma.filter( - (type) => !excludeModels.find((e) => type.name.toLowerCase().startsWith(e.toLowerCase())) + (type) => + !excludeModels.some((e) => type.name.toLowerCase().startsWith(e.toLowerCase())) && + // exclude delegate aux related types + !type.name.toLowerCase().includes(DELEGATE_AUX_RELATION_PREFIX) ); const models: DMMF.Model[] = prismaClientDmmf.datamodel.models.filter( @@ -236,7 +243,8 @@ export class ZodSchemaGenerator { const moduleNames: string[] = []; for (let i = 0; i < inputObjectTypes.length; i += 1) { - const fields = inputObjectTypes[i]?.fields; + // exclude delegate aux fields + const fields = inputObjectTypes[i]?.fields?.filter((f) => !f.name.startsWith(DELEGATE_AUX_RELATION_PREFIX)); const name = inputObjectTypes[i]?.name; if (!generateUnchecked && name.includes('Unchecked')) { diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index db0c2a7bb..f9bffa4c1 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ +import { DELEGATE_AUX_RELATION_PREFIX } from '@zenstackhq/runtime'; import { getForeignKeyFields, hasAttribute, indentString, + isDelegateModel, isDiscriminatorField, type PluginOptions, } from '@zenstackhq/sdk'; @@ -67,7 +69,11 @@ export default class Transformer { const filePath = path.join(Transformer.outputPath, `enums/${name}.schema.ts`); const content = `${this.generateImportZodStatement()}\n${this.generateExportSchemaStatement( `${name}`, - `z.enum(${JSON.stringify(enumType.values)})` + `z.enum(${JSON.stringify( + enumType.values + // exclude fields generated for delegate models + .filter((v) => !v.startsWith(DELEGATE_AUX_RELATION_PREFIX)) + )})` )}`; this.sourceFiles.push(this.project.createSourceFile(filePath, content, { overwrite: true })); generated.push(enumType.name); @@ -243,12 +249,16 @@ export default class Transformer { !isFieldRef && (inputType.namespace === 'prisma' || isEnum) ) { - if (inputType.type !== this.originalName && typeof inputType.type === 'string') { - this.addSchemaImport(inputType.type); + // reduce concrete input types to their delegate base types + // e.g.: "UserCreateNestedOneWithoutDelegate_aux_PostInput" => "UserCreateWithoutAssetInput" + const mappedInputType = this.mapDelegateInputType(inputType, contextDataModel); + + if (mappedInputType.type !== this.originalName && typeof mappedInputType.type === 'string') { + this.addSchemaImport(mappedInputType.type); } const contextField = contextDataModel?.fields.find((f) => f.name === field.name); - result.push(this.generatePrismaStringLine(field, inputType, lines.length, contextField)); + result.push(this.generatePrismaStringLine(field, mappedInputType, lines.length, contextField)); } } @@ -289,6 +299,33 @@ export default class Transformer { return [[` ${fieldName} ${resString} `, field, true]]; } + private mapDelegateInputType(inputType: PrismaDMMF.InputTypeRef, contextDataModel: DataModel | undefined) { + let processedInputType = inputType; + // captures: model name and operation, "Without" part that references a concrete model, + // and the "Input" or "NestedInput" suffix + const match = inputType.type.match(/^(\S+?)((NestedOne)?WithoutDelegate_aux\S+?)((Nested)?Input)$/); + if (match) { + let mappedInputTypeName = match[1]; + + if (contextDataModel) { + // find the parent delegate model and replace the "Without" part with it + const delegateBase = contextDataModel.superTypes + .map((t) => t.ref) + .filter((t) => t && isDelegateModel(t))?.[0]; + if (delegateBase) { + mappedInputTypeName += `Without${upperCaseFirst(delegateBase.name)}`; + } + } + + // "Input" or "NestedInput" suffix + mappedInputTypeName += match[4]; + + processedInputType = { ...inputType, type: mappedInputTypeName }; + // console.log('Replacing type', inputTyp.type, 'with', processedInputType.type); + } + return processedInputType; + } + wrapWithZodValidators( mainValidators: string | string[], field: PrismaDMMF.SchemaArg, diff --git a/tests/regression/tests/issue-1993.test.ts b/tests/regression/tests/issue-1993.test.ts new file mode 100644 index 000000000..b261209a7 --- /dev/null +++ b/tests/regression/tests/issue-1993.test.ts @@ -0,0 +1,45 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1993', () => { + it('regression', async () => { + await loadSchema( + ` +enum UserType { + UserLocal + UserGoogle +} + +model User { + id String @id @default(cuid()) + companyId String? + type UserType + + @@delegate(type) + + userFolders UserFolder[] + + @@allow('all', true) +} + +model UserLocal extends User { + email String + password String +} + +model UserGoogle extends User { + googleId String +} + +model UserFolder { + id String @id @default(cuid()) + userId String + path String + + user User @relation(fields: [userId], references: [id]) + + @@allow('all', true) +} `, + { pushDb: false, fullZod: true, compile: true, output: 'lib/zenstack' } + ); + }); +}); From ff5d94fe53fa164828ec5494c2da3ce77715b758 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:55:02 -0800 Subject: [PATCH 2/2] additional fixes --- .../schema/src/plugins/zod/transformer.ts | 35 +++++++++---- packages/sdk/src/model-meta-generator.ts | 50 +----------------- packages/sdk/src/utils.ts | 51 +++++++++++++++++++ tests/regression/tests/issue-1993.test.ts | 20 +++++++- 4 files changed, 98 insertions(+), 58 deletions(-) diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index f9bffa4c1..8e7364669 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -2,6 +2,7 @@ import { DELEGATE_AUX_RELATION_PREFIX } from '@zenstackhq/runtime'; import { getForeignKeyFields, + getRelationBackLink, hasAttribute, indentString, isDelegateModel, @@ -251,7 +252,10 @@ export default class Transformer { ) { // reduce concrete input types to their delegate base types // e.g.: "UserCreateNestedOneWithoutDelegate_aux_PostInput" => "UserCreateWithoutAssetInput" - const mappedInputType = this.mapDelegateInputType(inputType, contextDataModel); + let mappedInputType = inputType; + if (contextDataModel) { + mappedInputType = this.mapDelegateInputType(inputType, contextDataModel, field.name); + } if (mappedInputType.type !== this.originalName && typeof mappedInputType.type === 'string') { this.addSchemaImport(mappedInputType.type); @@ -299,8 +303,23 @@ export default class Transformer { return [[` ${fieldName} ${resString} `, field, true]]; } - private mapDelegateInputType(inputType: PrismaDMMF.InputTypeRef, contextDataModel: DataModel | undefined) { + private mapDelegateInputType( + inputType: PrismaDMMF.InputTypeRef, + contextDataModel: DataModel, + contextFieldName: string + ) { + // input type mapping is only relevant for relation inherited from delegate models + const contextField = contextDataModel.fields.find((f) => f.name === contextFieldName); + if (!contextField || !isDataModel(contextField.type.reference?.ref)) { + return inputType; + } + + if (!contextField.$inheritedFrom || !isDelegateModel(contextField.$inheritedFrom)) { + return inputType; + } + let processedInputType = inputType; + // captures: model name and operation, "Without" part that references a concrete model, // and the "Input" or "NestedInput" suffix const match = inputType.type.match(/^(\S+?)((NestedOne)?WithoutDelegate_aux\S+?)((Nested)?Input)$/); @@ -308,12 +327,11 @@ export default class Transformer { let mappedInputTypeName = match[1]; if (contextDataModel) { - // find the parent delegate model and replace the "Without" part with it - const delegateBase = contextDataModel.superTypes - .map((t) => t.ref) - .filter((t) => t && isDelegateModel(t))?.[0]; - if (delegateBase) { - mappedInputTypeName += `Without${upperCaseFirst(delegateBase.name)}`; + // get the opposite side of the relation field, which should be of the proper + // delegate base type + const oppositeRelationField = getRelationBackLink(contextField); + if (oppositeRelationField) { + mappedInputTypeName += `Without${upperCaseFirst(oppositeRelationField.name)}`; } } @@ -321,7 +339,6 @@ export default class Transformer { mappedInputTypeName += match[4]; processedInputType = { ...inputType, type: mappedInputTypeName }; - // console.log('Replacing type', inputTyp.type, 'with', processedInputType.type); } return processedInputType; } diff --git a/packages/sdk/src/model-meta-generator.ts b/packages/sdk/src/model-meta-generator.ts index 71b4246ca..88e064512 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -24,16 +24,15 @@ import { ExpressionContext, getAttribute, getAttributeArg, - getAttributeArgLiteral, getAttributeArgs, getAuthDecl, getDataModels, getInheritedFromDelegate, getLiteral, + getRelationBackLink, getRelationField, hasAttribute, isAuthInvocation, - isDelegateModel, isEnumFieldReference, isForeignKeyField, isIdField, @@ -289,7 +288,7 @@ function writeFields( if (dmField) { // metadata specific to DataModelField - const backlink = getBackLink(dmField); + const backlink = getRelationBackLink(dmField); const fkMapping = generateForeignKeyMapping(dmField); if (backlink) { @@ -336,51 +335,6 @@ function writeFields( writer.write(','); } -function getBackLink(field: DataModelField) { - if (!field.type.reference?.ref || !isDataModel(field.type.reference?.ref)) { - return undefined; - } - - const relName = getRelationName(field); - - let sourceModel: DataModel; - if (field.$inheritedFrom && isDelegateModel(field.$inheritedFrom)) { - // field is inherited from a delegate model, use it as the source - sourceModel = field.$inheritedFrom; - } else { - // otherwise use the field's container model as the source - sourceModel = field.$container as DataModel; - } - - const targetModel = field.type.reference.ref as DataModel; - - for (const otherField of targetModel.fields) { - if (otherField === field) { - // backlink field is never self - continue; - } - if (otherField.type.reference?.ref === sourceModel) { - if (relName) { - const otherRelName = getRelationName(otherField); - if (relName === otherRelName) { - return otherField; - } - } else { - return otherField; - } - } - } - return undefined; -} - -function getRelationName(field: DataModelField) { - const relAttr = getAttribute(field, '@relation'); - if (!relAttr) { - return undefined; - } - return getAttributeArgLiteral(relAttr, 'name'); -} - function getAttributes(target: DataModelField | DataModel | TypeDefField): RuntimeAttribute[] { return target.attributes .map((attr) => { diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index ecb6895eb..93118b5f5 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -632,3 +632,54 @@ export function getInheritanceChain(from: DataModel, to: DataModel): DataModel[] return undefined; } + +/** + * Get the opposite side of a relation field. + */ +export function getRelationBackLink(field: DataModelField) { + if (!field.type.reference?.ref || !isDataModel(field.type.reference?.ref)) { + return undefined; + } + + const relName = getRelationName(field); + + let sourceModel: DataModel; + if (field.$inheritedFrom && isDelegateModel(field.$inheritedFrom)) { + // field is inherited from a delegate model, use it as the source + sourceModel = field.$inheritedFrom; + } else { + // otherwise use the field's container model as the source + sourceModel = field.$container as DataModel; + } + + const targetModel = field.type.reference.ref as DataModel; + + for (const otherField of targetModel.fields) { + if (otherField === field) { + // backlink field is never self + continue; + } + if (otherField.type.reference?.ref === sourceModel) { + if (relName) { + const otherRelName = getRelationName(otherField); + if (relName === otherRelName) { + return otherField; + } + } else { + return otherField; + } + } + } + return undefined; +} + +/** + * Get the relation name of a relation field. + */ +export function getRelationName(field: DataModelField) { + const relAttr = getAttribute(field, '@relation'); + if (!relAttr) { + return undefined; + } + return getAttributeArgLiteral(relAttr, 'name'); +} diff --git a/tests/regression/tests/issue-1993.test.ts b/tests/regression/tests/issue-1993.test.ts index b261209a7..23561f8e4 100644 --- a/tests/regression/tests/issue-1993.test.ts +++ b/tests/regression/tests/issue-1993.test.ts @@ -2,7 +2,7 @@ import { loadSchema } from '@zenstackhq/testtools'; describe('issue 1993', () => { it('regression', async () => { - await loadSchema( + const { zodSchemas } = await loadSchema( ` enum UserType { UserLocal @@ -41,5 +41,23 @@ model UserFolder { } `, { pushDb: false, fullZod: true, compile: true, output: 'lib/zenstack' } ); + + expect( + zodSchemas.input.UserLocalInputSchema.create.safeParse({ + data: { + email: 'test@example.com', + password: 'password', + }, + }) + ).toMatchObject({ success: true }); + + expect( + zodSchemas.input.UserFolderInputSchema.create.safeParse({ + data: { + path: '/', + userId: '1', + }, + }) + ).toMatchObject({ success: true }); }); });