Skip to content

Commit d42dc32

Browse files
authored
fix(delegate): clean up generated zod schemas for delegate auxiliary fields (#2003)
1 parent 721c938 commit d42dc32

File tree

5 files changed

+186
-56
lines changed

5 files changed

+186
-56
lines changed

packages/schema/src/plugins/zod/generator.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DELEGATE_AUX_RELATION_PREFIX } from '@zenstackhq/runtime';
12
import {
23
ExpressionContext,
34
PluginError,
@@ -88,12 +89,18 @@ export class ZodSchemaGenerator {
8889
(o) => !excludeModels.find((e) => e === o.model)
8990
);
9091

91-
// TODO: better way of filtering than string startsWith?
9292
const inputObjectTypes = prismaClientDmmf.schema.inputObjectTypes.prisma.filter(
93-
(type) => !excludeModels.find((e) => type.name.toLowerCase().startsWith(e.toLocaleLowerCase()))
93+
(type) =>
94+
!excludeModels.some((e) => type.name.toLowerCase().startsWith(e.toLocaleLowerCase())) &&
95+
// exclude delegate aux related types
96+
!type.name.toLowerCase().includes(DELEGATE_AUX_RELATION_PREFIX)
9497
);
98+
9599
const outputObjectTypes = prismaClientDmmf.schema.outputObjectTypes.prisma.filter(
96-
(type) => !excludeModels.find((e) => type.name.toLowerCase().startsWith(e.toLowerCase()))
100+
(type) =>
101+
!excludeModels.some((e) => type.name.toLowerCase().startsWith(e.toLowerCase())) &&
102+
// exclude delegate aux related types
103+
!type.name.toLowerCase().includes(DELEGATE_AUX_RELATION_PREFIX)
97104
);
98105

99106
const models: DMMF.Model[] = prismaClientDmmf.datamodel.models.filter(
@@ -236,7 +243,8 @@ export class ZodSchemaGenerator {
236243

237244
const moduleNames: string[] = [];
238245
for (let i = 0; i < inputObjectTypes.length; i += 1) {
239-
const fields = inputObjectTypes[i]?.fields;
246+
// exclude delegate aux fields
247+
const fields = inputObjectTypes[i]?.fields?.filter((f) => !f.name.startsWith(DELEGATE_AUX_RELATION_PREFIX));
240248
const name = inputObjectTypes[i]?.name;
241249

242250
if (!generateUnchecked && name.includes('Unchecked')) {

packages/schema/src/plugins/zod/transformer.ts

+58-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/* eslint-disable @typescript-eslint/ban-ts-comment */
2+
import { DELEGATE_AUX_RELATION_PREFIX } from '@zenstackhq/runtime';
23
import {
34
getForeignKeyFields,
5+
getRelationBackLink,
46
hasAttribute,
57
indentString,
8+
isDelegateModel,
69
isDiscriminatorField,
710
type PluginOptions,
811
} from '@zenstackhq/sdk';
@@ -67,7 +70,11 @@ export default class Transformer {
6770
const filePath = path.join(Transformer.outputPath, `enums/${name}.schema.ts`);
6871
const content = `${this.generateImportZodStatement()}\n${this.generateExportSchemaStatement(
6972
`${name}`,
70-
`z.enum(${JSON.stringify(enumType.values)})`
73+
`z.enum(${JSON.stringify(
74+
enumType.values
75+
// exclude fields generated for delegate models
76+
.filter((v) => !v.startsWith(DELEGATE_AUX_RELATION_PREFIX))
77+
)})`
7178
)}`;
7279
this.sourceFiles.push(this.project.createSourceFile(filePath, content, { overwrite: true }));
7380
generated.push(enumType.name);
@@ -243,12 +250,19 @@ export default class Transformer {
243250
!isFieldRef &&
244251
(inputType.namespace === 'prisma' || isEnum)
245252
) {
246-
if (inputType.type !== this.originalName && typeof inputType.type === 'string') {
247-
this.addSchemaImport(inputType.type);
253+
// reduce concrete input types to their delegate base types
254+
// e.g.: "UserCreateNestedOneWithoutDelegate_aux_PostInput" => "UserCreateWithoutAssetInput"
255+
let mappedInputType = inputType;
256+
if (contextDataModel) {
257+
mappedInputType = this.mapDelegateInputType(inputType, contextDataModel, field.name);
258+
}
259+
260+
if (mappedInputType.type !== this.originalName && typeof mappedInputType.type === 'string') {
261+
this.addSchemaImport(mappedInputType.type);
248262
}
249263

250264
const contextField = contextDataModel?.fields.find((f) => f.name === field.name);
251-
result.push(this.generatePrismaStringLine(field, inputType, lines.length, contextField));
265+
result.push(this.generatePrismaStringLine(field, mappedInputType, lines.length, contextField));
252266
}
253267
}
254268

@@ -289,6 +303,46 @@ export default class Transformer {
289303
return [[` ${fieldName} ${resString} `, field, true]];
290304
}
291305

306+
private mapDelegateInputType(
307+
inputType: PrismaDMMF.InputTypeRef,
308+
contextDataModel: DataModel,
309+
contextFieldName: string
310+
) {
311+
// input type mapping is only relevant for relation inherited from delegate models
312+
const contextField = contextDataModel.fields.find((f) => f.name === contextFieldName);
313+
if (!contextField || !isDataModel(contextField.type.reference?.ref)) {
314+
return inputType;
315+
}
316+
317+
if (!contextField.$inheritedFrom || !isDelegateModel(contextField.$inheritedFrom)) {
318+
return inputType;
319+
}
320+
321+
let processedInputType = inputType;
322+
323+
// captures: model name and operation, "Without" part that references a concrete model,
324+
// and the "Input" or "NestedInput" suffix
325+
const match = inputType.type.match(/^(\S+?)((NestedOne)?WithoutDelegate_aux\S+?)((Nested)?Input)$/);
326+
if (match) {
327+
let mappedInputTypeName = match[1];
328+
329+
if (contextDataModel) {
330+
// get the opposite side of the relation field, which should be of the proper
331+
// delegate base type
332+
const oppositeRelationField = getRelationBackLink(contextField);
333+
if (oppositeRelationField) {
334+
mappedInputTypeName += `Without${upperCaseFirst(oppositeRelationField.name)}`;
335+
}
336+
}
337+
338+
// "Input" or "NestedInput" suffix
339+
mappedInputTypeName += match[4];
340+
341+
processedInputType = { ...inputType, type: mappedInputTypeName };
342+
}
343+
return processedInputType;
344+
}
345+
292346
wrapWithZodValidators(
293347
mainValidators: string | string[],
294348
field: PrismaDMMF.SchemaArg,

packages/sdk/src/model-meta-generator.ts

+2-48
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,15 @@ import {
2424
ExpressionContext,
2525
getAttribute,
2626
getAttributeArg,
27-
getAttributeArgLiteral,
2827
getAttributeArgs,
2928
getAuthDecl,
3029
getDataModels,
3130
getInheritedFromDelegate,
3231
getLiteral,
32+
getRelationBackLink,
3333
getRelationField,
3434
hasAttribute,
3535
isAuthInvocation,
36-
isDelegateModel,
3736
isEnumFieldReference,
3837
isForeignKeyField,
3938
isIdField,
@@ -289,7 +288,7 @@ function writeFields(
289288
if (dmField) {
290289
// metadata specific to DataModelField
291290

292-
const backlink = getBackLink(dmField);
291+
const backlink = getRelationBackLink(dmField);
293292
const fkMapping = generateForeignKeyMapping(dmField);
294293

295294
if (backlink) {
@@ -336,51 +335,6 @@ function writeFields(
336335
writer.write(',');
337336
}
338337

339-
function getBackLink(field: DataModelField) {
340-
if (!field.type.reference?.ref || !isDataModel(field.type.reference?.ref)) {
341-
return undefined;
342-
}
343-
344-
const relName = getRelationName(field);
345-
346-
let sourceModel: DataModel;
347-
if (field.$inheritedFrom && isDelegateModel(field.$inheritedFrom)) {
348-
// field is inherited from a delegate model, use it as the source
349-
sourceModel = field.$inheritedFrom;
350-
} else {
351-
// otherwise use the field's container model as the source
352-
sourceModel = field.$container as DataModel;
353-
}
354-
355-
const targetModel = field.type.reference.ref as DataModel;
356-
357-
for (const otherField of targetModel.fields) {
358-
if (otherField === field) {
359-
// backlink field is never self
360-
continue;
361-
}
362-
if (otherField.type.reference?.ref === sourceModel) {
363-
if (relName) {
364-
const otherRelName = getRelationName(otherField);
365-
if (relName === otherRelName) {
366-
return otherField;
367-
}
368-
} else {
369-
return otherField;
370-
}
371-
}
372-
}
373-
return undefined;
374-
}
375-
376-
function getRelationName(field: DataModelField) {
377-
const relAttr = getAttribute(field, '@relation');
378-
if (!relAttr) {
379-
return undefined;
380-
}
381-
return getAttributeArgLiteral(relAttr, 'name');
382-
}
383-
384338
function getAttributes(target: DataModelField | DataModel | TypeDefField): RuntimeAttribute[] {
385339
return target.attributes
386340
.map((attr) => {

packages/sdk/src/utils.ts

+51
Original file line numberDiff line numberDiff line change
@@ -632,3 +632,54 @@ export function getInheritanceChain(from: DataModel, to: DataModel): DataModel[]
632632

633633
return undefined;
634634
}
635+
636+
/**
637+
* Get the opposite side of a relation field.
638+
*/
639+
export function getRelationBackLink(field: DataModelField) {
640+
if (!field.type.reference?.ref || !isDataModel(field.type.reference?.ref)) {
641+
return undefined;
642+
}
643+
644+
const relName = getRelationName(field);
645+
646+
let sourceModel: DataModel;
647+
if (field.$inheritedFrom && isDelegateModel(field.$inheritedFrom)) {
648+
// field is inherited from a delegate model, use it as the source
649+
sourceModel = field.$inheritedFrom;
650+
} else {
651+
// otherwise use the field's container model as the source
652+
sourceModel = field.$container as DataModel;
653+
}
654+
655+
const targetModel = field.type.reference.ref as DataModel;
656+
657+
for (const otherField of targetModel.fields) {
658+
if (otherField === field) {
659+
// backlink field is never self
660+
continue;
661+
}
662+
if (otherField.type.reference?.ref === sourceModel) {
663+
if (relName) {
664+
const otherRelName = getRelationName(otherField);
665+
if (relName === otherRelName) {
666+
return otherField;
667+
}
668+
} else {
669+
return otherField;
670+
}
671+
}
672+
}
673+
return undefined;
674+
}
675+
676+
/**
677+
* Get the relation name of a relation field.
678+
*/
679+
export function getRelationName(field: DataModelField) {
680+
const relAttr = getAttribute(field, '@relation');
681+
if (!relAttr) {
682+
return undefined;
683+
}
684+
return getAttributeArgLiteral(relAttr, 'name');
685+
}
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { loadSchema } from '@zenstackhq/testtools';
2+
3+
describe('issue 1993', () => {
4+
it('regression', async () => {
5+
const { zodSchemas } = await loadSchema(
6+
`
7+
enum UserType {
8+
UserLocal
9+
UserGoogle
10+
}
11+
12+
model User {
13+
id String @id @default(cuid())
14+
companyId String?
15+
type UserType
16+
17+
@@delegate(type)
18+
19+
userFolders UserFolder[]
20+
21+
@@allow('all', true)
22+
}
23+
24+
model UserLocal extends User {
25+
email String
26+
password String
27+
}
28+
29+
model UserGoogle extends User {
30+
googleId String
31+
}
32+
33+
model UserFolder {
34+
id String @id @default(cuid())
35+
userId String
36+
path String
37+
38+
user User @relation(fields: [userId], references: [id])
39+
40+
@@allow('all', true)
41+
} `,
42+
{ pushDb: false, fullZod: true, compile: true, output: 'lib/zenstack' }
43+
);
44+
45+
expect(
46+
zodSchemas.input.UserLocalInputSchema.create.safeParse({
47+
data: {
48+
49+
password: 'password',
50+
},
51+
})
52+
).toMatchObject({ success: true });
53+
54+
expect(
55+
zodSchemas.input.UserFolderInputSchema.create.safeParse({
56+
data: {
57+
path: '/',
58+
userId: '1',
59+
},
60+
})
61+
).toMatchObject({ success: true });
62+
});
63+
});

0 commit comments

Comments
 (0)