From 7bc2fe57d79f8f6fa97cfe677b8ca73b8ead3acf Mon Sep 17 00:00:00 2001 From: Lukas Wolf <76838159+wolflu05@users.noreply.github.com> Date: Fri, 22 May 2026 17:31:43 +0200 Subject: [PATCH 1/3] feat(orm): implement delegateMap attribute (#2676) --- packages/language/res/stdlib.zmodel | 7 + .../src/validators/datamodel-validator.ts | 113 +++++++++ packages/language/test/delegate.test.ts | 226 ++++++++++++++++++ packages/orm/src/client/crud-types.ts | 13 +- .../orm/src/client/crud/operations/base.ts | 5 +- packages/orm/src/client/query-utils.ts | 5 + packages/schema/src/schema.ts | 6 + packages/sdk/src/ts-schema-generator.ts | 27 +++ tests/e2e/orm/client-api/delegate.test.ts | 16 +- tests/e2e/orm/schemas/delegate/schema.ts | 44 +++- tests/e2e/orm/schemas/delegate/schema.zmodel | 18 +- tests/e2e/orm/schemas/delegate/typecheck.ts | 32 +-- 12 files changed, 471 insertions(+), 41 deletions(-) diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 761e577a4..99dafdbd5 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -641,6 +641,13 @@ attribute @@prisma.passthrough(_ text: String) */ attribute @@delegate(_ discriminator: FieldReference) +/** + * Maps a delegate sub-model to a specific discriminator value. If not set the sub-model name is used as the discriminator value by default. + * + * @param value: A string literal or enum member used as the discriminator. + */ +attribute @@delegateMap(_ value: Any) + /** * Used for specifying operator classes for GIN index. */ diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index 0048bd20d..e8ddbe89c 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -5,21 +5,27 @@ import { ArrayExpr, DataField, DataModel, + DataModelAttribute, ReferenceExpr, TypeDef, + isDataField, isDataModel, isEnum, + isReferenceExpr, isStringLiteral, isTypeDef, } from '../generated/ast'; import { getAllAttributes, getAllFields, + getAttribute, + getAttributeArg, getModelIdFields, getModelUniqueFields, getUniqueFields, hasAttribute, isDelegateModel, + isEnumFieldReference, } from '../utils'; import { validateAttributeApplication } from './attribute-application-validator'; import { validateDuplicatedDeclarations, type AstValidator } from './common'; @@ -36,6 +42,7 @@ export default class DataModelValidator implements AstValidator { this.validateMixins(dm, accept); } this.validateInherits(dm, accept); + this.validateDelegateMap(dm, accept); } private validateFields(dm: DataModel, accept: ValidationAcceptor) { @@ -489,6 +496,112 @@ export default class DataModelValidator implements AstValidator { todo.push(...current.mixins.map((mixin) => mixin.ref!)); } } + + private validateDelegateMap(dm: DataModel, accept: ValidationAcceptor) { + const delegateMapAttrs = dm.attributes.filter((attr) => attr.decl.$refText === '@@delegateMap'); + if (delegateMapAttrs.length > 1) { + accept('error', 'Model can include at most one @@delegateMap attribute', { + node: delegateMapAttrs[1]!, + }); + } + + const delegateMapAttr = delegateMapAttrs[0]; + if (delegateMapAttr) { + if (!dm.baseModel) { + accept('error', '`@@delegateMap` can only be used on models that extend a delegate base model', { + node: delegateMapAttr, + }); + } else if (dm.baseModel.ref) { + this.validateDelegateMapValue(dm.baseModel.ref, delegateMapAttr, accept); + } + } + + if (!hasAttribute(dm, '@@delegate')) { + return; + } + + const subModels = dm.$container.declarations.filter(isDataModel).filter((model) => model.baseModel?.ref === dm); + + if (subModels.length === 0) { + return; + } + + const seen = new Map(); + subModels.forEach((model) => { + const value = this.getDelegateMapRawValue(model) ?? model.name; + const existing = seen.get(value); + if (existing) { + accept( + 'error', + `Duplicate @@delegateMap value "${value}" on models "${existing.name}" and "${model.name}"`, + { node: model }, + ); + } else { + seen.set(value, model); + } + }); + } + + private getDelegateMapRawValue(dm: DataModel): string | undefined { + const delegateMapAttr = dm.attributes.find((attr) => attr.decl.$refText === '@@delegateMap'); + const valueExpr = delegateMapAttr?.args[0]?.value; + if (!valueExpr) { + return undefined; + } + if (isStringLiteral(valueExpr)) { + return valueExpr.value; + } + if (isEnumFieldReference(valueExpr)) { + return valueExpr.target.ref?.name; + } + return undefined; + } + + private validateDelegateMapValue(baseModel: DataModel, attr: DataModelAttribute, accept: ValidationAcceptor) { + const delegateMapValueExpr = attr.args[0]?.value; + if (!delegateMapValueExpr) { + accept('error', '`@@delegateMap` expects a value', { node: attr }); + return; + } + + const delegateAttr = getAttribute(baseModel, '@@delegate'); + const discriminatorArg = delegateAttr && getAttributeArg(delegateAttr, 'discriminator'); + const discriminatorRef = discriminatorArg && isReferenceExpr(discriminatorArg) ? discriminatorArg.target.ref : undefined; + + if (!discriminatorRef || !isDataField(discriminatorRef)) { + return; + } + + const discriminatorType = discriminatorRef.type; + const discriminatorEnum = discriminatorType.reference?.ref; + + if (isEnumFieldReference(delegateMapValueExpr)) { + if (!isEnum(discriminatorEnum)) { + accept('error', '`@@delegateMap` enum value cannot be used when the discriminator field is String', { + node: delegateMapValueExpr, + }); + return; + } + + if (delegateMapValueExpr.target.ref?.$container !== discriminatorEnum) { + accept('error', '`@@delegateMap` enum value must come from the discriminator enum type', { + node: delegateMapValueExpr, + }); + } + return; + } + + if (isStringLiteral(delegateMapValueExpr)) { + if (discriminatorType.type !== 'String') { + accept('error', '`@@delegateMap` string value must match a String discriminator field', { + node: delegateMapValueExpr, + }); + } + return; + } + + accept('error', '`@@delegateMap` expects a string literal or enum value', { node: delegateMapValueExpr }); + } } export interface MissingOppositeRelationData { diff --git a/packages/language/test/delegate.test.ts b/packages/language/test/delegate.test.ts index 31d61d35d..0cbc8f8a1 100644 --- a/packages/language/test/delegate.test.ts +++ b/packages/language/test/delegate.test.ts @@ -164,4 +164,230 @@ describe('Delegate Tests', () => { `, ); }); + + it('supports delegate map values', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum AssetType { + ASSET_KIND_VIDEO + ASSET_KIND_IMAGE + } + + model Asset { + id Int @id @default(autoincrement()) + type AssetType + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap(ASSET_KIND_VIDEO) + } + + model Image extends Asset { + format String + @@delegateMap(ASSET_KIND_IMAGE) + } + `, + ); + }); + + it('supports delegate maps on multi-level delegate inheritance', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model Asset { + id Int @id @default(autoincrement()) + type String + @@delegate(type) + } + + model Media extends Asset { + title String + mediaType String + @@delegate(mediaType) + @@delegateMap("media_type") + } + + model Video extends Media { + url String + @@delegateMap("video_type") + } + `, + ); + }); + + it('allows partial delegate map values', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model Asset { + id Int @id @default(autoincrement()) + type String + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap("video") + } + + model Image extends Asset { + format String + } + `, + ); + }); + + it('rejects duplicate delegate map values', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model Asset { + id Int @id @default(autoincrement()) + type String + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap("Image") + } + + model Image extends Asset { + format String + } + `, + 'Duplicate @@delegateMap value', + ); + }); + + it('rejects multiple delegate map attributes on one model', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model Asset { + id Int @id @default(autoincrement()) + type String + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap("video") + @@delegateMap("clip") + } + `, + 'at most one @@delegateMap', + ); + }); + + it('rejects enum value from a different discriminator enum', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum AssetType { + ASSET_KIND_VIDEO + ASSET_KIND_IMAGE + } + + enum VideoType { + VIDEO_KIND_TRAILER + } + + model Asset { + id Int @id @default(autoincrement()) + type AssetType + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap(VIDEO_KIND_TRAILER) + } + `, + 'enum value must come from the discriminator enum type', + ); + }); + + it('rejects enum value when discriminator is String', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum AssetType { + ASSET_KIND_VIDEO + ASSET_KIND_IMAGE + } + + model Asset { + id Int @id @default(autoincrement()) + type String + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap(ASSET_KIND_VIDEO) + } + `, + 'enum value cannot be used when the discriminator field is String', + ); + }); + + it('rejects string value when discriminator is enum', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + enum AssetType { + ASSET_KIND_VIDEO + ASSET_KIND_IMAGE + } + + model Asset { + id Int @id @default(autoincrement()) + type AssetType + @@delegate(type) + } + + model Video extends Asset { + url String + @@delegateMap("video") + } + `, + 'string value must match a String discriminator field', + ); + }); }); diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 6842f4058..3ed16e8a6 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -10,6 +10,7 @@ import type { GetEnum, GetEnums, GetModel, + GetModelDelegateMapValue, GetModelDiscriminator, GetModelField, GetModelFields, @@ -148,7 +149,7 @@ type FlatModelResult< // Builds a discriminated union from a delegate model's direct sub-models. Recursion depth // is tracked via a tuple (each level appends a `0` element); the hard stop at length 10 // ensures the type terminates even for the generic SchemaDef case. -// Each union branch fixes the parent discriminator field to the sub-model name. +// Each union branch fixes the parent discriminator field to the sub-model's delegate map value. // When a sub-model is itself a delegate, we recurse into its own sub-models so all // concrete leaf types appear in the union, each picking up the accumulated // discriminator overrides from both levels. @@ -161,7 +162,9 @@ type DelegateUnionResult< Depth extends readonly 0[] = [], > = Depth['length'] extends 10 // hard stop so generic SchemaDef never infinite-loops ? SubModel extends string - ? FlatModelResult & { [K in GetModelDiscriminator]: SubModel } + ? FlatModelResult & { + [K in GetModelDiscriminator]: GetModelDelegateMapValue; + } : never : SubModel extends string // typescript union distribution ? IsDelegateModel extends true @@ -169,10 +172,12 @@ type DelegateUnionResult< // concrete leaf types appear in the union, each picking up the accumulated // discriminator overrides from both levels DelegateUnionResult, Omit, [...Depth, 0]> & { - [K in GetModelDiscriminator]: SubModel; + [K in GetModelDiscriminator]: GetModelDelegateMapValue; } : // leaf model — produce a flat scalar result and fix the discriminator - FlatModelResult & { [K in GetModelDiscriminator]: SubModel } + FlatModelResult & { + [K in GetModelDiscriminator]: GetModelDelegateMapValue; + } : never; type ModelSelectResult< diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index d96718765..a5ef4d6e7 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -36,6 +36,7 @@ import { ensureArray, extractIdFields, flattenCompoundUniqueFilters, + getDelegateDiscriminatorValue, getDiscriminatorField, getField, getIdValues, @@ -600,7 +601,7 @@ export abstract class BaseOperationHandler { const discriminatorField = getDiscriminatorField(this.schema, model); invariant(discriminatorField, `Base model "${model}" must have a discriminator field`); - thisCreateFields[discriminatorField] = forModel; + thisCreateFields[discriminatorField] = getDelegateDiscriminatorValue(this.schema, forModel); // create base model entity const baseEntity: any = await this.create( @@ -1013,7 +1014,7 @@ export abstract class BaseOperationHandler { remainingFields[field] = value; } }); - thisCreateFields[discriminatorField] = forModel; + thisCreateFields[discriminatorField] = getDelegateDiscriminatorValue(this.schema, forModel); thisCreateRows.push(thisCreateFields); remainingFieldRows.push(remainingFields); } diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index 9fc830a25..e67e8db3d 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -381,6 +381,11 @@ export function getDiscriminatorField(schema: SchemaDef, model: string) { return discriminator.value.field; } +export function getDelegateDiscriminatorValue(schema: SchemaDef, model: string) { + const modelDef = requireModel(schema, model); + return modelDef.delegateMap ?? modelDef.name; +} + export function getDelegateDescendantModels( schema: SchemaDef, model: string, diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 4696b795c..b3cbc0bcc 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -35,6 +35,7 @@ export type ModelDef = { isDelegate?: boolean; subModels?: readonly string[]; isView?: boolean; + delegateMap?: string; }; export type AttributeApplication = { @@ -178,6 +179,11 @@ export type GetModelDiscriminator> = + Exclude['delegateMap'], undefined> extends never + ? Model + : Exclude['delegateMap'], undefined>; + export type GetModelFieldType< Schema extends SchemaDef, Model extends GetModels, diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 14d74ae32..4a5dc8acd 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -438,6 +438,14 @@ export class TsSchemaGenerator { ? [ts.factory.createPropertyAssignment('isDelegate', ts.factory.createTrue())] : []), + ...(() => { + const delegateMapValue = this.getDelegateMapValue(dm); + if (delegateMapValue === undefined) { + return []; + } + return [ts.factory.createPropertyAssignment('delegateMap', this.createLiteralNode(delegateMapValue))]; + })(), + // subModels ...(subModels.length > 0 ? [ @@ -464,6 +472,25 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(fields, true); } + private getDelegateMapValue(dm: DataModel): string | undefined { + const delegateMapAttr = getAttribute(dm, '@@delegateMap'); + if (!delegateMapAttr) { + return undefined; + } + const valueExpr = delegateMapAttr.args[0]?.value; + if (!valueExpr) { + return undefined; + } + if (isLiteralExpr(valueExpr)) { + const literal = this.getLiteral(valueExpr); + return typeof literal === 'string' ? literal : undefined; + } + if (isReferenceExpr(valueExpr) && isEnumField(valueExpr.target.ref)) { + return valueExpr.target.ref.name; + } + return undefined; + } + private getSubModels(dm: DataModel) { return dm.$container.declarations .filter(isDataModel) diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index 6eda28368..af205c40e 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -55,8 +55,8 @@ describe('Delegate model tests ', () => { duration: 100, url: 'abc', rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', + assetType: 'ASSET_KIND_VIDEO', + videoType: 'VIDEO_KIND_RATED', }); // create entity with relation @@ -91,7 +91,7 @@ describe('Delegate model tests ', () => { id: expect.any(Number), format: 'png', galleryId: expect.any(Number), - assetType: 'Image', + assetType: 'ASSET_KIND_IMAGE', }); // discriminator field cannot be set on create @@ -101,7 +101,7 @@ describe('Delegate model tests ', () => { duration: 100, url: 'abc', rating: 5, - videoType: 'RatedVideo', + videoType: 'VIDEO_KIND_RATED', }, }), ).toBeRejectedByValidation(['videoType']); @@ -223,8 +223,8 @@ describe('Delegate model tests ', () => { createdAt: expect.any(Date), duration: 100, rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', + assetType: 'ASSET_KIND_VIDEO', + videoType: 'VIDEO_KIND_RATED', }; // include all base fields @@ -309,7 +309,7 @@ describe('Delegate model tests ', () => { }, }), ).resolves.toMatchObject({ - assets: [{ id: v.id, assetType: 'Video' }], + assets: [{ id: v.id, assetType: 'ASSET_KIND_VIDEO' }], ratedVideos: [{ url: 'abc', rating: 5 }], }); }); @@ -653,7 +653,7 @@ describe('Delegate model tests ', () => { client.ratedVideo.update({ where: { id: 2 }, // @ts-expect-error - data: { videoType: 'MyVideo' }, + data: { videoType: 'VIDEO_KIND_RATED' }, }), ).toBeRejectedByValidation(['videoType']); }); diff --git a/tests/e2e/orm/schemas/delegate/schema.ts b/tests/e2e/orm/schemas/delegate/schema.ts index 65caeff8b..2b560462e 100644 --- a/tests/e2e/orm/schemas/delegate/schema.ts +++ b/tests/e2e/orm/schemas/delegate/schema.ts @@ -140,7 +140,7 @@ export class SchemaType implements SchemaDef { }, assetType: { name: "assetType", - type: "String", + type: "AssetKind", isDiscriminator: true } }, @@ -213,7 +213,7 @@ export class SchemaType implements SchemaDef { }, assetType: { name: "assetType", - type: "String", + type: "AssetKind", originModel: "Asset", isDiscriminator: true }, @@ -229,12 +229,13 @@ export class SchemaType implements SchemaDef { }, videoType: { name: "videoType", - type: "String", + type: "VideoKind", isDiscriminator: true } }, attributes: [ - { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("videoType") }] } + { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("videoType") }] }, + { name: "@@delegateMap", args: [{ name: "value", value: ExpressionUtils.literal("ASSET_KIND_VIDEO") }] } ] as readonly AttributeApplication[], idFields: ["id"], uniqueFields: { @@ -242,6 +243,7 @@ export class SchemaType implements SchemaDef { url: { type: "String" } }, isDelegate: true, + delegateMap: "ASSET_KIND_VIDEO", subModels: ["RatedVideo"] }, RatedVideo: { @@ -302,7 +304,7 @@ export class SchemaType implements SchemaDef { }, assetType: { name: "assetType", - type: "String", + type: "AssetKind", originModel: "Asset", isDiscriminator: true }, @@ -320,7 +322,7 @@ export class SchemaType implements SchemaDef { }, videoType: { name: "videoType", - type: "String", + type: "VideoKind", originModel: "Video", isDiscriminator: true }, @@ -344,11 +346,16 @@ export class SchemaType implements SchemaDef { ] as readonly string[] } }, + attributes: [ + { name: "@@delegateMap", args: [{ name: "value", value: ExpressionUtils.literal("ASSET_KIND_VIDEO") }] }, + { name: "@@delegateMap", args: [{ name: "value", value: ExpressionUtils.literal("VIDEO_KIND_RATED") }] } + ] as readonly AttributeApplication[], idFields: ["id"], uniqueFields: { id: { type: "Int" }, url: { type: "String" } - } + }, + delegateMap: "VIDEO_KIND_RATED" }, Image: { name: "Image", @@ -408,7 +415,7 @@ export class SchemaType implements SchemaDef { }, assetType: { name: "assetType", - type: "String", + type: "AssetKind", originModel: "Asset", isDiscriminator: true }, @@ -432,10 +439,14 @@ export class SchemaType implements SchemaDef { ] as readonly string[] } }, + attributes: [ + { name: "@@delegateMap", args: [{ name: "value", value: ExpressionUtils.literal("ASSET_KIND_IMAGE") }] } + ] as readonly AttributeApplication[], idFields: ["id"], uniqueFields: { id: { type: "Int" } - } + }, + delegateMap: "ASSET_KIND_IMAGE" }, Gallery: { name: "Gallery", @@ -460,6 +471,21 @@ export class SchemaType implements SchemaDef { } } } as const; + enums = { + AssetKind: { + name: "AssetKind", + values: { + ASSET_KIND_VIDEO: "ASSET_KIND_VIDEO", + ASSET_KIND_IMAGE: "ASSET_KIND_IMAGE" + } + }, + VideoKind: { + name: "VideoKind", + values: { + VIDEO_KIND_RATED: "VIDEO_KIND_RATED" + } + } + } as const; authType = "User" as const; plugins = {}; } diff --git a/tests/e2e/orm/schemas/delegate/schema.zmodel b/tests/e2e/orm/schemas/delegate/schema.zmodel index f97bd8bec..f4cf735bb 100644 --- a/tests/e2e/orm/schemas/delegate/schema.zmodel +++ b/tests/e2e/orm/schemas/delegate/schema.zmodel @@ -18,6 +18,15 @@ model Comment { assetId Int? } +enum AssetKind { + ASSET_KIND_VIDEO + ASSET_KIND_IMAGE +} + +enum VideoKind { + VIDEO_KIND_RATED +} + model Asset { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -26,7 +35,7 @@ model Asset { owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId Int? comments Comment[] - assetType String + assetType AssetKind @@index([ownerId]) @@ -36,21 +45,26 @@ model Asset { model Video extends Asset { duration Int url String @unique - videoType String + videoType VideoKind @@delegate(videoType) + @@delegateMap(ASSET_KIND_VIDEO) } model RatedVideo extends Video { rating Int user User? @relation(name: 'direct', fields: [userId], references: [id], onDelete: Cascade) userId Int? + + @@delegateMap(VIDEO_KIND_RATED) } model Image extends Asset { format String gallery Gallery? @relation(fields: [galleryId], references: [id], onDelete: Cascade) galleryId Int? + + @@delegateMap(ASSET_KIND_IMAGE) } model Gallery { diff --git a/tests/e2e/orm/schemas/delegate/typecheck.ts b/tests/e2e/orm/schemas/delegate/typecheck.ts index 05c56bd51..4e0ffce37 100644 --- a/tests/e2e/orm/schemas/delegate/typecheck.ts +++ b/tests/e2e/orm/schemas/delegate/typecheck.ts @@ -18,7 +18,7 @@ async function find() { console.log(r.rating); // discriminated union narrows sub-model fields - if (r.assetType === 'Video') { + if (r.assetType === 'ASSET_KIND_VIDEO') { // video console.log(r.duration); // only one choice `RatedVideo` @@ -38,7 +38,7 @@ async function find() { }); // @ts-expect-error console.log(r1.duration); - if (r1.assetType === 'Video') { + if (r1.assetType === 'ASSET_KIND_VIDEO') { // @ts-expect-error console.log(r1.duration); } @@ -53,7 +53,7 @@ async function find() { console.log(r2.assets[0]?.rating); // discriminated union narrows sub-model fields when queried via relation - if (r2.assets[0]?.assetType === 'Video') { + if (r2.assets[0]?.assetType === 'ASSET_KIND_VIDEO') { // video console.log(r2.assets[0]?.duration); // only one choice `RatedVideo` @@ -75,29 +75,29 @@ async function find() { async function create() { // delegate creation is not allowed // @ts-expect-error - client.asset.create({ data: { assetType: 'Video' } }); + client.asset.create({ data: { assetType: 'ASSET_KIND_VIDEO' } }); // @ts-expect-error - client.asset.createMany({ data: [{ assetType: 'Video' }] }); + client.asset.createMany({ data: [{ assetType: 'ASSET_KIND_VIDEO' }] }); // @ts-expect-error - client.asset.upsert({ where: { id: 1 }, create: { assetType: 'Video' }, update: { assetType: 'Video' } }); + client.asset.upsert({ where: { id: 1 }, create: { assetType: 'ASSET_KIND_VIDEO' }, update: { assetType: 'ASSET_KIND_VIDEO' } }); // nested creation is not allowed either // @ts-expect-error - client.user.create({ data: { assets: { create: { assetType: 'Video' } } } }); + client.user.create({ data: { assets: { create: { assetType: 'ASSET_KIND_VIDEO' } } } }); // @ts-expect-error - client.user.create({ data: { assets: { connectOrCreate: { where: { id: 1 }, create: { assetType: 'Video' } } } } }); + client.user.create({ data: { assets: { connectOrCreate: { where: { id: 1 }, create: { assetType: 'ASSET_KIND_VIDEO' } } } } }); // @ts-expect-error - client.user.update({ where: { id: 1 }, data: { assets: { create: { assetType: 'Video' } } } }); + client.user.update({ where: { id: 1 }, data: { assets: { create: { assetType: 'ASSET_KIND_VIDEO' } } } }); client.user.update({ where: { id: 1 }, // @ts-expect-error - data: { assets: { connectOrCreate: { where: { id: 1 }, create: { assetType: 'Video' } } } }, + data: { assets: { connectOrCreate: { where: { id: 1 }, create: { assetType: 'ASSET_KIND_VIDEO' } } } }, }); client.user.update({ where: { id: 1 }, data: { // @ts-expect-error - assets: { upsert: { where: { id: 1 }, create: { assetType: 'Video' }, update: { assetType: 'Video' } } }, + assets: { upsert: { where: { id: 1 }, create: { assetType: 'ASSET_KIND_VIDEO' }, update: { assetType: 'ASSET_KIND_VIDEO' } } }, }, }); @@ -109,7 +109,7 @@ async function create() { // rating: 5, // duration: 100, // // @ts-expect-error - // assetType: 'Video', + // assetType: 'ASSET_KIND_VIDEO', // }, // }); } @@ -133,7 +133,7 @@ async function update() { // data: { // url: 'valid-update', // // @ts-expect-error - // assetType: 'Video', + // assetType: 'ASSET_KIND_VIDEO', // }, // }); @@ -143,7 +143,7 @@ async function update() { // data: { // format: 'jpg', // // @ts-expect-error - // assetType: 'Image', + // assetType: 'ASSET_KIND_IMAGE', // }, // }); @@ -152,7 +152,7 @@ async function update() { where: { rating: { gt: 3 } }, data: { // @ts-expect-error - assetType: 'Video', + assetType: 'ASSET_KIND_VIDEO', }, }); @@ -164,7 +164,7 @@ async function update() { // update: { // rating: 4, // // @ts-expect-error - // assetType: 'Video', + // assetType: 'ASSET_KIND_VIDEO', // }, // }); } From 87644178b39bdbf04b855eaec4dbddfcab39db97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:31:58 -0700 Subject: [PATCH 2/3] [CI] Bump version 3.7.1 (#2678) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- package.json | 2 +- packages/auth-adapters/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/clients/client-helpers/package.json | 2 +- packages/clients/fetch-client/package.json | 2 +- packages/clients/tanstack-query/package.json | 2 +- packages/common-helpers/package.json | 2 +- packages/config/eslint-config/package.json | 2 +- packages/config/tsdown-config/package.json | 2 +- packages/config/typescript-config/package.json | 2 +- packages/config/vitest-config/package.json | 2 +- packages/create-zenstack/package.json | 2 +- packages/ide/vscode/package.json | 2 +- packages/language/package.json | 2 +- packages/orm/package.json | 2 +- packages/plugins/policy/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- packages/zod/package.json | 2 +- samples/orm/package.json | 2 +- tests/e2e/package.json | 2 +- tests/regression/package.json | 2 +- tests/runtimes/bun/package.json | 2 +- tests/runtimes/edge-runtime/package.json | 2 +- 26 files changed, 26 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index aa33612b1..a8076383a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 3cf3fdbe9..ad40b27ee 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index a9e249866..931b71ca3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 0b56834c4..83fc10fba 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index ae57eaaa4..3ffc77c2b 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 8621537ca..54172d272 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index a9f515cf9..7ce8e86de 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 884592155..8651d6432 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index 47aecd712..5203ce764 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.7.0", + "version": "3.7.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 402d1ca0b..673bb69f5 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.7.0", + "version": "3.7.1", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 205e198b0..713cabb63 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.7.0", + "version": "3.7.1", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 485e23c4b..8515e7629 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 2436781e9..c6f85e050 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.7.0", + "version": "3.7.1", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 759600620..75a47aa41 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index e0ecf5dd2..a72106534 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index a46bebc9f..fbea698c0 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index ee83aa1c1..efd85e628 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f9b522a0c..54a23b2bf 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index ed89b5274..1f9cf3349 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index caabc170b..d26f90f1b 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 43a9f340b..3cae1587d 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.7.0", + "version": "3.7.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index 678d72f62..c8df85405 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.7.0", + "version": "3.7.1", "description": "", "main": "index.js", "private": true, diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 3c85cf2f4..228ebee18 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.7.0", + "version": "3.7.1", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 2d5ade929..6b1f35c80 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.7.0", + "version": "3.7.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 279f4a16e..bd6407caf 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.7.0", + "version": "3.7.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 9b407e2b3..32c5d6a2a 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.7.0", + "version": "3.7.1", "private": true, "type": "module", "scripts": { From 76febc90d4143d1f1909d0dd83158b569cc0fd15 Mon Sep 17 00:00:00 2001 From: sanny-io <3054653+sanny-io@users.noreply.github.com> Date: Fri, 22 May 2026 08:33:41 -0700 Subject: [PATCH 3/3] fix: invalid prisma schema with format args (#2677) --- packages/cli/test/prisma-schema-gen.test.ts | 49 +++++++++++++++++++ .../sdk/src/prisma/prisma-schema-generator.ts | 7 ++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/prisma-schema-gen.test.ts diff --git a/packages/cli/test/prisma-schema-gen.test.ts b/packages/cli/test/prisma-schema-gen.test.ts new file mode 100644 index 000000000..34aed5d6f --- /dev/null +++ b/packages/cli/test/prisma-schema-gen.test.ts @@ -0,0 +1,49 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; + +describe('Prisma schema generation tests', () => { + it('strips format args from id functions', async () => { + const model = await loadSchema(` +model User { + id Int @id @default(autoincrement()) + + cuid String @default(cuid()) + cuid1 String @default(cuid(1, 'cuid1_%s')) + cuid2 String @default(cuid(2, 'cuid2_%s')) + + uuid String @default(uuid()) + uuid4 String @default(uuid(4, 'uuid4_%s')) + uuid7 String @default(uuid(7, 'uuid7_%s')) + + ulid String @default(ulid()) + ulid1 String @default(ulid('ulid_%s')) + + nanoid String @default(nanoid()) + nanoid12 String @default(nanoid(12, 'nanoid12_%s')) +} + `); + + const generator = new PrismaSchemaGenerator(model); + const prismaSchemaText = await generator.generate(); + + expect(prismaSchemaText.includes('cuid()')).toBe(true); + expect(prismaSchemaText.includes('cuid(1)')).toBe(true); + expect(prismaSchemaText.includes('cuid(2)')).toBe(true); + expect(prismaSchemaText.includes('cuid1_%s')).toBe(false); + expect(prismaSchemaText.includes('cuid2_%s')).toBe(false); + + expect(prismaSchemaText.includes('uuid()')).toBe(true); + expect(prismaSchemaText.includes('uuid(4)')).toBe(true); + expect(prismaSchemaText.includes('uuid(7)')).toBe(true); + expect(prismaSchemaText.includes('uuid4_%s')).toBe(false); + expect(prismaSchemaText.includes('uuid7_%s')).toBe(false); + + expect(prismaSchemaText.match(/ulid\(\)/g)).toHaveLength(2); + expect(prismaSchemaText.includes('ulid_%s')).toBe(false); + + expect(prismaSchemaText.includes('nanoid()')).toBe(true); + expect(prismaSchemaText.includes('nanoid(12)')).toBe(true); + expect(prismaSchemaText.includes('nanoid12_%s')).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 815403cac..57fd9c6e9 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -67,6 +67,7 @@ const IDENTIFIER_NAME_MAX_LENGTH = 50 - DELEGATE_AUX_RELATION_PREFIX.length; // Datasource fields that only exist in ZModel but not in Prisma schema const NON_PRISMA_DATASOURCE_FIELDS = ['defaultSchema']; +const ID_FUNCTIONS = ['uuid', 'ulid', 'cuid', 'nanoid']; /** * Generates Prisma schema file @@ -383,7 +384,11 @@ export class PrismaSchemaGenerator { makeFunctionCall(node: InvocationExpr): PrismaFunctionCall { return new PrismaFunctionCall( node.function.ref!.name, - node.args.map((arg) => { + + // strip format args from id functions + node.args.filter((_, i) => ( + !(ID_FUNCTIONS.includes(node.function.ref!.name) && (node.function.ref!.name === 'ulid' && i === 0 || i === 1)) + )).map((arg) => { const val = match(arg.value) .when(isStringLiteral, (v) => `"${v.value}"`) .when(isLiteralExpr, (v) => v.value.toString())