Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make @@delegateMap single-use to avoid ambiguous mappings.

Without @@@once, multiple @@delegateMap entries on one model are accepted, while downstream resolution reads only one and silently ignores others.

Proposed fix
-attribute @@delegateMap(_ value: Any)
+attribute @@delegateMap(_ value: Any) @@@once
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
attribute @@delegateMap(_ value: Any)
attribute @@delegateMap(_ value: Any) @@@once
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/language/res/stdlib.zmodel` at line 649, The @@delegateMap attribute
must be marked single-use to prevent multiple mappings being accepted but only
one being resolved; update the attribute declaration for @@delegateMap to
include the single-use modifier (e.g., add @@@once or the equivalent single-use
flag) so the parser enforces only one @@delegateMap per model and rejects
additional entries, ensuring unique mapping resolution for the model.


/**
* Used for specifying operator classes for GIN index.
*/
Expand Down
97 changes: 97 additions & 0 deletions packages/language/src/validators/datamodel-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import {
ArrayExpr,
DataField,
DataModel,
DataModelAttribute,
ReferenceExpr,
TypeDef,
isDataField,
isDataModel,
isEnumField,
isEnum,
isReferenceExpr,
isStringLiteral,
isTypeDef,
} from '../generated/ast';
Expand Down Expand Up @@ -36,6 +40,7 @@ export default class DataModelValidator implements AstValidator<DataModel> {
this.validateMixins(dm, accept);
}
this.validateInherits(dm, accept);
this.validateDelegateMap(dm, accept);
}

private validateFields(dm: DataModel, accept: ValidationAcceptor) {
Expand Down Expand Up @@ -489,6 +494,98 @@ export default class DataModelValidator implements AstValidator<DataModel> {
todo.push(...current.mixins.map((mixin) => mixin.ref!));
}
}

private validateDelegateMap(dm: DataModel, accept: ValidationAcceptor) {
const delegateMapAttr = dm.attributes.find((attr) => attr.decl.$refText === '@@delegateMap');
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 resolvedValues = subModels.map((model) => [model, this.getDelegateMapRawValue(model) ?? model.name] as const);
const seen = new Map<string, DataModel>();
resolvedValues.forEach(([model, value]) => {
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 as string;
}
if (isReferenceExpr(valueExpr) && isEnumField(valueExpr.target.ref)) {
return valueExpr.target.ref.name;
}
return undefined;
}

private validateDelegateMapValue(baseModel: DataModel, attr: DataModelAttribute, accept: ValidationAcceptor) {
const valueExpr = attr.args[0]?.value;
if (!valueExpr) {
accept('error', '`@@delegateMap` expects a value', { node: attr });
return;
}

const delegateAttr = baseModel.attributes.find((baseAttr) => baseAttr.decl.$refText === '@@delegate');
const discriminatorArg = delegateAttr?.args.find((arg) => arg.$resolvedParam?.name === 'discriminator');
const discriminatorRef =
discriminatorArg && isReferenceExpr(discriminatorArg.value) ? discriminatorArg.value.target.ref : undefined;

if (!discriminatorRef || !isDataField(discriminatorRef)) {
return;
}

const discriminatorType = discriminatorRef.type;
if (isReferenceExpr(valueExpr) && isEnumField(valueExpr.target.ref)) {
const enumDecl = discriminatorType.reference?.ref;
if (!isEnum(enumDecl) || valueExpr.target.ref.$container !== enumDecl) {
accept('error', '`@@delegateMap` enum value must come from the discriminator enum type', {
node: valueExpr,
});
}
return;
}

if (isStringLiteral(valueExpr)) {
if (discriminatorType.type !== 'String') {
accept('error', '`@@delegateMap` string value must match a String discriminator field', {
node: valueExpr,
});
}
return;
}

accept('error', '`@@delegateMap` expects a string literal or enum value', { node: valueExpr });
}
}

export interface MissingOppositeRelationData {
Expand Down
117 changes: 117 additions & 0 deletions packages/language/test/delegate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,121 @@ 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('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 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',
);
});
});
13 changes: 9 additions & 4 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
GetEnum,
GetEnums,
GetModel,
GetModelDelegateMapValue,
GetModelDiscriminator,
GetModelField,
GetModelFields,
Expand Down Expand Up @@ -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.
Expand All @@ -161,18 +162,22 @@ type DelegateUnionResult<
Depth extends readonly 0[] = [],
> = Depth['length'] extends 10 // hard stop so generic SchemaDef never infinite-loops
? SubModel extends string
? FlatModelResult<Schema, SubModel, Omit, Options> & { [K in GetModelDiscriminator<Schema, Model>]: SubModel }
? FlatModelResult<Schema, SubModel, Omit, Options> & {
[K in GetModelDiscriminator<Schema, Model>]: GetModelDelegateMapValue<Schema, SubModel>;
}
: never
: SubModel extends string // typescript union distribution
? IsDelegateModel<Schema, SubModel> extends true
? // sub-model is itself a delegate — 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
DelegateUnionResult<Schema, SubModel, Options, GetSubModels<Schema, SubModel>, Omit, [...Depth, 0]> & {
[K in GetModelDiscriminator<Schema, Model>]: SubModel;
[K in GetModelDiscriminator<Schema, Model>]: GetModelDelegateMapValue<Schema, SubModel>;
}
: // leaf model — produce a flat scalar result and fix the discriminator
FlatModelResult<Schema, SubModel, Omit, Options> & { [K in GetModelDiscriminator<Schema, Model>]: SubModel }
FlatModelResult<Schema, SubModel, Omit, Options> & {
[K in GetModelDiscriminator<Schema, Model>]: GetModelDelegateMapValue<Schema, SubModel>;
}
: never;

type ModelSelectResult<
Expand Down
5 changes: 3 additions & 2 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
ensureArray,
extractIdFields,
flattenCompoundUniqueFilters,
getDelegateDiscriminatorValue,
getDiscriminatorField,
getField,
getIdValues,
Expand Down Expand Up @@ -600,7 +601,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {

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(
Expand Down Expand Up @@ -1013,7 +1014,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
remainingFields[field] = value;
}
});
thisCreateFields[discriminatorField] = forModel;
thisCreateFields[discriminatorField] = getDelegateDiscriminatorValue(this.schema, forModel);
thisCreateRows.push(thisCreateFields);
remainingFieldRows.push(remainingFields);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/orm/src/client/query-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? model;
}
Comment on lines +384 to +387
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use canonical model name for default discriminator fallback.

Line 386 falls back to the input model string. Because lookup is case-insensitive, passing "video" can produce "video" instead of schema model name "Video" when delegateMap is not set.

Proposed fix
 export function getDelegateDiscriminatorValue(schema: SchemaDef, model: string) {
     const modelDef = requireModel(schema, model);
-    return modelDef.delegateMap ?? model;
+    return modelDef.delegateMap ?? modelDef.name;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orm/src/client/query-utils.ts` around lines 384 - 387, The fallback
for the discriminator currently returns the raw input parameter `model`, which
can preserve incorrect casing; update getDelegateDiscriminatorValue to use the
canonical model name from the resolved model definition instead of the input
string when delegateMap is undefined: call requireModel(schema, model) as
already done, then return modelDef.delegateMap ?? modelDef.name (or the
canonical name field on the modelDef) so the schema's canonical casing is used;
keep delegateMap precedence and only use the modelDef canonical name as the
default.


export function getDelegateDescendantModels(
schema: SchemaDef,
model: string,
Expand Down
6 changes: 6 additions & 0 deletions packages/schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type ModelDef = {
isDelegate?: boolean;
subModels?: readonly string[];
isView?: boolean;
delegateMap?: string;
};

export type AttributeApplication = {
Expand Down Expand Up @@ -178,6 +179,11 @@ export type GetModelDiscriminator<Schema extends SchemaDef, Model extends GetMod
: never]: true;
};

export type GetModelDelegateMapValue<Schema extends SchemaDef, Model extends GetModels<Schema>> =
Exclude<GetModel<Schema, Model>['delegateMap'], undefined> extends never
? Model
: Exclude<GetModel<Schema, Model>['delegateMap'], undefined>;

export type GetModelFieldType<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Expand Down
27 changes: 27 additions & 0 deletions packages/sdk/src/ts-schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? [
Expand All @@ -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)
Expand Down
Loading
Loading