From 258b8a72955aacb3f723823bd6b99d0dc4a3e2ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:00:02 +0000 Subject: [PATCH 1/7] feat(orm): implement discriminated union types for delegate (polymorphic) models Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/678a87a1-1e67-44f9-95ee-e77f3d5e81cc Co-authored-by: motopods <58200641+motopods@users.noreply.github.com> --- packages/orm/src/client/crud-types.ts | 88 ++++++++++++++------- tests/e2e/orm/schemas/delegate/typecheck.ts | 40 +++++----- 2 files changed, 79 insertions(+), 49 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 99fb9e69c..2cb672456 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -12,14 +12,17 @@ import type { GetEnum, GetEnums, GetModel, + GetModelDiscriminator, GetModelField, GetModelFields, GetModelFieldType, GetModels, + GetSubModels, GetTypeDefField, GetTypeDefFields, GetTypeDefFieldType, GetTypeDefs, + IsDelegateModel, ModelFieldIsOptional, NonRelationFields, ProcedureDef, @@ -70,27 +73,19 @@ export type DefaultModelResult< Options extends QueryOptions = QueryOptions, Optional = false, Array = false, + // Guard: if Model is the generic `string` type (which happens when Schema is the base + // SchemaDef interface), skip all delegate expansion. Checking [string] extends [Model] + // is O(1) and short-circuits before any of the more expensive type computations run, + // keeping the total instantiation count within TypeScript's recursion budget. + _IsGenericModel = [string] extends [Model] ? true : false, > = WrapType< - { - [Key in NonRelationFields as ShouldOmitField extends true - ? never - : Key]: MapModelFieldType; - }, - // TODO: revisit how to efficiently implement discriminated sub model types - // IsDelegateModel extends true - // ? // delegate model's selection result is a union of all sub-models - // DelegateUnionResult, Omit> - // : { - // [Key in NonRelationFields as ShouldOmitField< - // Schema, - // Model, - // Options, - // Key, - // Omit - // > extends true - // ? never - // : Key]: MapModelFieldType; - // }, + _IsGenericModel extends true + ? // generic model — return flat type immediately to avoid expensive recursion + { [Key in NonRelationFields as ShouldOmitField extends true ? never : Key]: MapModelFieldType } + : IsDelegateModel extends true + ? // delegate model's selection result is a union of all sub-models + DelegateUnionResult, Omit> + : { [Key in NonRelationFields as ShouldOmitField extends true ? never : Key]: MapModelFieldType }, Optional, Array >; @@ -135,15 +130,50 @@ type SchemaLevelOmit< Field extends GetModelFields, > = GetModelField['omit'] extends true ? true : false; -// type DelegateUnionResult< -// Schema extends SchemaDef, -// Model extends GetModels, -// Options extends QueryOptions, -// SubModel extends GetModels, -// Omit = undefined, -// > = SubModel extends string // typescript union distribution -// ? DefaultModelResult & { [K in GetModelDiscriminator]: SubModel } // fixate discriminated field -// : never; +// Flat scalar-only result for a single model (no delegate expansion). Used as the leaf case +// in DelegateUnionResult so that we never call DefaultModelResult from within itself. +type FlatModelResult< + Schema extends SchemaDef, + Model extends GetModels, + Omit, + Options extends QueryOptions, +> = { + [Key in NonRelationFields as ShouldOmitField extends true + ? never + : Key]: MapModelFieldType; +}; + +// Builds a discriminated union from a delegate model's direct sub-models. A depth counter +// (tuple length) prevents infinite type instantiation when Schema is the generic SchemaDef. +// Each union branch fixes the parent discriminator field to the sub-model name. +// When a sub-model is itself a delegate, we recurse into its own sub-models instead of +// stopping, so multi-level delegation (e.g. Asset → Video → RatedVideo) is fully expanded. +type DelegateUnionResult< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, + SubModel extends GetModels, + Omit = undefined, + _Depth extends readonly 0[] = [], +> = _Depth['length'] extends 10 // hard stop so generic SchemaDef never infinite-loops + ? never + : SubModel extends string // typescript union distribution + ? IsDelegateModel 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, + Omit, + [..._Depth, 0] + > & { [K in GetModelDiscriminator]: SubModel } + : // leaf model — produce a flat scalar result and fix the discriminator + FlatModelResult & + { [K in GetModelDiscriminator]: SubModel } + : never; type ModelSelectResult< Schema extends SchemaDef, diff --git a/tests/e2e/orm/schemas/delegate/typecheck.ts b/tests/e2e/orm/schemas/delegate/typecheck.ts index 9bf394766..76e36115f 100644 --- a/tests/e2e/orm/schemas/delegate/typecheck.ts +++ b/tests/e2e/orm/schemas/delegate/typecheck.ts @@ -17,16 +17,16 @@ async function find() { // @ts-expect-error console.log(r.rating); - // TODO: discriminated sub-model fields - // if (r.assetType === 'Video') { - // // video - // console.log(r.duration); - // // only one choice `RatedVideo` - // console.log(r.rating); - // } else { - // // image - // console.log(r.format); - // } + // discriminated union narrows sub-model fields + if (r.assetType === 'Video') { + // video + console.log(r.duration); + // only one choice `RatedVideo` + console.log(r.rating); + } else { + // image + console.log(r.format); + } // if fields are explicitly selected, then no sub-model fields are available const r1 = await client.asset.findFirstOrThrow({ @@ -52,16 +52,16 @@ async function find() { // @ts-expect-error console.log(r2.assets[0]?.rating); - // TODO: discriminated sub-model fields - // if (r2.assets[0]?.assetType === 'Video') { - // // video - // console.log(r2.assets[0]?.duration); - // // only one choice `RatedVideo` - // console.log(r2.assets[0]?.rating); - // } else { - // // image - // console.log(r2.assets[0]?.format); - // } + // discriminated union narrows sub-model fields when queried via relation + if (r2.assets[0]?.assetType === 'Video') { + // video + console.log(r2.assets[0]?.duration); + // only one choice `RatedVideo` + console.log(r2.assets[0]?.rating); + } else { + // image + console.log(r2.assets[0]?.format); + } // sub model behavior const r3 = await client.ratedVideo.findFirstOrThrow(); From 93cfa6b7e3718eb9f01fcb206345e4257a8936b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:02:54 +0000 Subject: [PATCH 2/7] fix: address review comments (comment formatting) Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/678a87a1-1e67-44f9-95ee-e77f3d5e81cc Co-authored-by: motopods <58200641+motopods@users.noreply.github.com> --- packages/orm/src/client/crud-types.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 2cb672456..8f77af16b 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -74,7 +74,7 @@ export type DefaultModelResult< Optional = false, Array = false, // Guard: if Model is the generic `string` type (which happens when Schema is the base - // SchemaDef interface), skip all delegate expansion. Checking [string] extends [Model] + // SchemaDef interface), skip all delegate expansion. Checking [string] extends [Model] // is O(1) and short-circuits before any of the more expensive type computations run, // keeping the total instantiation count within TypeScript's recursion budget. _IsGenericModel = [string] extends [Model] ? true : false, @@ -143,11 +143,13 @@ type FlatModelResult< : Key]: MapModelFieldType; }; -// Builds a discriminated union from a delegate model's direct sub-models. A depth counter -// (tuple length) prevents infinite type instantiation when Schema is the generic SchemaDef. +// 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. -// When a sub-model is itself a delegate, we recurse into its own sub-models instead of -// stopping, so multi-level delegation (e.g. Asset → Video → RatedVideo) is fully expanded. +// 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. type DelegateUnionResult< Schema extends SchemaDef, Model extends GetModels, From 34add8c78fc2d16646fa6b299868f674ed70872c Mon Sep 17 00:00:00 2001 From: motopods <58200641+motopods@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:54:06 +0800 Subject: [PATCH 3/7] Revert "feat(orm): implement discriminated union return types for delegate (polymorphic) models" --- packages/orm/src/client/crud-types.ts | 90 +++++++-------------- tests/e2e/orm/schemas/delegate/typecheck.ts | 40 ++++----- 2 files changed, 49 insertions(+), 81 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 8f77af16b..99fb9e69c 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -12,17 +12,14 @@ import type { GetEnum, GetEnums, GetModel, - GetModelDiscriminator, GetModelField, GetModelFields, GetModelFieldType, GetModels, - GetSubModels, GetTypeDefField, GetTypeDefFields, GetTypeDefFieldType, GetTypeDefs, - IsDelegateModel, ModelFieldIsOptional, NonRelationFields, ProcedureDef, @@ -73,19 +70,27 @@ export type DefaultModelResult< Options extends QueryOptions = QueryOptions, Optional = false, Array = false, - // Guard: if Model is the generic `string` type (which happens when Schema is the base - // SchemaDef interface), skip all delegate expansion. Checking [string] extends [Model] - // is O(1) and short-circuits before any of the more expensive type computations run, - // keeping the total instantiation count within TypeScript's recursion budget. - _IsGenericModel = [string] extends [Model] ? true : false, > = WrapType< - _IsGenericModel extends true - ? // generic model — return flat type immediately to avoid expensive recursion - { [Key in NonRelationFields as ShouldOmitField extends true ? never : Key]: MapModelFieldType } - : IsDelegateModel extends true - ? // delegate model's selection result is a union of all sub-models - DelegateUnionResult, Omit> - : { [Key in NonRelationFields as ShouldOmitField extends true ? never : Key]: MapModelFieldType }, + { + [Key in NonRelationFields as ShouldOmitField extends true + ? never + : Key]: MapModelFieldType; + }, + // TODO: revisit how to efficiently implement discriminated sub model types + // IsDelegateModel extends true + // ? // delegate model's selection result is a union of all sub-models + // DelegateUnionResult, Omit> + // : { + // [Key in NonRelationFields as ShouldOmitField< + // Schema, + // Model, + // Options, + // Key, + // Omit + // > extends true + // ? never + // : Key]: MapModelFieldType; + // }, Optional, Array >; @@ -130,52 +135,15 @@ type SchemaLevelOmit< Field extends GetModelFields, > = GetModelField['omit'] extends true ? true : false; -// Flat scalar-only result for a single model (no delegate expansion). Used as the leaf case -// in DelegateUnionResult so that we never call DefaultModelResult from within itself. -type FlatModelResult< - Schema extends SchemaDef, - Model extends GetModels, - Omit, - Options extends QueryOptions, -> = { - [Key in NonRelationFields as ShouldOmitField extends true - ? never - : Key]: MapModelFieldType; -}; - -// 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. -// 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. -type DelegateUnionResult< - Schema extends SchemaDef, - Model extends GetModels, - Options extends QueryOptions, - SubModel extends GetModels, - Omit = undefined, - _Depth extends readonly 0[] = [], -> = _Depth['length'] extends 10 // hard stop so generic SchemaDef never infinite-loops - ? never - : SubModel extends string // typescript union distribution - ? IsDelegateModel 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, - Omit, - [..._Depth, 0] - > & { [K in GetModelDiscriminator]: SubModel } - : // leaf model — produce a flat scalar result and fix the discriminator - FlatModelResult & - { [K in GetModelDiscriminator]: SubModel } - : never; +// type DelegateUnionResult< +// Schema extends SchemaDef, +// Model extends GetModels, +// Options extends QueryOptions, +// SubModel extends GetModels, +// Omit = undefined, +// > = SubModel extends string // typescript union distribution +// ? DefaultModelResult & { [K in GetModelDiscriminator]: SubModel } // fixate discriminated field +// : never; type ModelSelectResult< Schema extends SchemaDef, diff --git a/tests/e2e/orm/schemas/delegate/typecheck.ts b/tests/e2e/orm/schemas/delegate/typecheck.ts index 76e36115f..9bf394766 100644 --- a/tests/e2e/orm/schemas/delegate/typecheck.ts +++ b/tests/e2e/orm/schemas/delegate/typecheck.ts @@ -17,16 +17,16 @@ async function find() { // @ts-expect-error console.log(r.rating); - // discriminated union narrows sub-model fields - if (r.assetType === 'Video') { - // video - console.log(r.duration); - // only one choice `RatedVideo` - console.log(r.rating); - } else { - // image - console.log(r.format); - } + // TODO: discriminated sub-model fields + // if (r.assetType === 'Video') { + // // video + // console.log(r.duration); + // // only one choice `RatedVideo` + // console.log(r.rating); + // } else { + // // image + // console.log(r.format); + // } // if fields are explicitly selected, then no sub-model fields are available const r1 = await client.asset.findFirstOrThrow({ @@ -52,16 +52,16 @@ async function find() { // @ts-expect-error console.log(r2.assets[0]?.rating); - // discriminated union narrows sub-model fields when queried via relation - if (r2.assets[0]?.assetType === 'Video') { - // video - console.log(r2.assets[0]?.duration); - // only one choice `RatedVideo` - console.log(r2.assets[0]?.rating); - } else { - // image - console.log(r2.assets[0]?.format); - } + // TODO: discriminated sub-model fields + // if (r2.assets[0]?.assetType === 'Video') { + // // video + // console.log(r2.assets[0]?.duration); + // // only one choice `RatedVideo` + // console.log(r2.assets[0]?.rating); + // } else { + // // image + // console.log(r2.assets[0]?.format); + // } // sub model behavior const r3 = await client.ratedVideo.findFirstOrThrow(); From a4cc3b5031b720d972fb72d5501a791dabc06394 Mon Sep 17 00:00:00 2001 From: motopods <58200641+motopods@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:36:16 +0800 Subject: [PATCH 4/7] implement `$is` sub-model filter for delegate base models (#8) --- packages/orm/src/client/crud-types.ts | 16 +++++ .../src/client/crud/dialects/base-dialect.ts | 59 +++++++++++++++++ packages/orm/src/client/zod/factory.ts | 17 +++++ tests/e2e/orm/client-api/delegate.test.ts | 63 +++++++++++++++++++ 4 files changed, 155 insertions(+) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index d29822209..e739575dd 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -347,6 +347,22 @@ export type WhereInput< AND?: OrArray>; OR?: WhereInput[]; NOT?: OrArray>; +} & (IsDelegateModel extends true + ? { $is?: SubModelWhereInput } + : object); + +/** + * Where filter that targets a specific sub-model of a delegate (polymorphic) base model. + * Keys are direct sub-model names; values are `WhereInput` for that sub-model. + * Multiple sub-model entries are combined with OR semantics. + */ +export type SubModelWhereInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, + ScalarOnly extends boolean = false, +> = { + [SubModel in GetSubModels]?: WhereInput | null; }; type FieldFilter< diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 1c7627547..cc3712052 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -23,6 +23,7 @@ import { ensureArray, flattenCompoundUniqueFilters, getDelegateDescendantModels, + getDiscriminatorField, getManyToManyRelation, getModelFields, getRelationForeignKeyFieldPairs, @@ -203,6 +204,11 @@ export abstract class BaseCrudDialect { result = this.and(result, _where['$expr'](this.eb)); } + // handle $is filter for delegate (polymorphic) base models + if ('$is' in _where && _where['$is'] != null && typeof _where['$is'] === 'object') { + result = this.and(result, this.buildIsFilter(model, modelAlias, _where['$is'] as Record)); + } + return result; } @@ -298,6 +304,59 @@ export abstract class BaseCrudDialect { .exhaustive(); } + /** + * Builds a filter expression for the `$is` operator on a delegate (polymorphic) base model. + * Each key in `payload` is a direct sub-model name; the value is an optional `WhereInput` for + * that sub-model. Multiple sub-model entries are combined with OR semantics. + */ + private buildIsFilter(model: string, modelAlias: string, payload: Record): Expression { + const discriminatorField = getDiscriminatorField(this.schema, model); + if (!discriminatorField) { + throw createInvalidInputError( + `"$is" filter is only supported on delegate models; "${model}" is not a delegate model. ` + + `Only models with a @@delegate attribute support the "$is" filter.`, + ); + } + + const discriminatorFieldDef = requireField(this.schema, model, discriminatorField); + const discriminatorTableAlias = discriminatorFieldDef.originModel ?? modelAlias; + const discriminatorRef = this.eb.ref(`${discriminatorTableAlias}.${discriminatorField}`); + + const conditions: Expression[] = []; + + for (const [subModelName, subWhere] of Object.entries(payload)) { + // discriminator must equal the sub-model name + const discriminatorCheck = this.eb(discriminatorRef, '=', subModelName); + + if (subWhere == null || (typeof subWhere === 'object' && Object.keys(subWhere).length === 0)) { + // no sub-model field filter — just check the discriminator + conditions.push(discriminatorCheck); + } else { + // build a correlated EXISTS subquery for sub-model-specific field filters + const subAlias = tmpAlias(`${modelAlias}__is__${subModelName}`); + const idFields = requireIdFields(this.schema, model); + + // correlate sub-model rows to the outer model rows via primary key + const joinConditions = idFields.map((idField) => + this.eb(this.eb.ref(`${subAlias}.${idField}`), '=', this.eb.ref(`${modelAlias}.${idField}`)), + ); + + const subWhereFilter = this.buildFilter(subModelName, subAlias, subWhere); + + const existsSubquery = this.eb + .selectFrom(`${subModelName} as ${subAlias}`) + .select(this.eb.lit(1).as('__exists')) + .where(this.and(...joinConditions, subWhereFilter)); + + conditions.push(this.and(discriminatorCheck, this.eb.exists(existsSubquery))); + } + } + + if (conditions.length === 0) return this.true(); + if (conditions.length === 1) return conditions[0]!; + return this.or(...conditions); + } + private buildRelationFilter(model: string, modelAlias: string, field: string, fieldDef: FieldDef, payload: any) { if (!fieldDef.array) { return this.buildToOneRelationFilter(model, modelAlias, field, fieldDef, payload); diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index f6149cab7..a185e7596 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -484,6 +484,23 @@ export class ZodSchemaFactory< // expression builder fields['$expr'] = z.custom((v) => typeof v === 'function', { error: '"$expr" must be a function' }).optional(); + // $is sub-model filter for delegate (polymorphic) base models + const modelDef = requireModel(this.schema, model); + if (modelDef.isDelegate && modelDef.subModels && modelDef.subModels.length > 0) { + const subModelSchema = z.object( + Object.fromEntries( + modelDef.subModels.map((subModel) => [ + subModel, + z + .lazy(() => this.makeWhereSchema(subModel, false, false, false, options)) + .nullish() + .optional(), + ]), + ), + ); + fields['$is'] = subModelSchema.optional(); + } + // logical operators fields['AND'] = this.orArray( z.lazy(() => this.makeWhereSchema(model, false, withoutRelationFields, false, options)), diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index e325aaa5b..b4075a88c 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -557,6 +557,69 @@ describe('Delegate model tests ', () => { }), ).toResolveFalsy(); }); + + it('works with $is sub-model filter on base model', async () => { + // add an Image so we can test OR semantics of $is + await client.image.create({ + data: { format: 'png', viewCount: 2 }, + }); + + // $is: { Video: {} } — all assets that are Videos (2 RatedVideos) + await expect( + client.asset.findMany({ + where: { $is: { Video: {} } }, + }), + ).toResolveWithLength(2); + + // $is: { Video: null } — null value also means "is a Video" + await expect( + client.asset.findMany({ + where: { $is: { Video: null } }, + }), + ).toResolveWithLength(2); + + // $is: { Video: { duration: { gt: 100 } } } — only v2 + await expect( + client.asset.findMany({ + where: { $is: { Video: { duration: { gt: 100 } } } }, + }), + ).toResolveWithLength(1); + + // $is combined with base-model field filter + await expect( + client.asset.findMany({ + where: { viewCount: { gt: 0 }, $is: { Video: { duration: { gt: 100 } } } }, + }), + ).toResolveWithLength(1); + + // $is: { Video: { duration: { gte: 100 } } } — both videos + await expect( + client.asset.findMany({ + where: { $is: { Video: { duration: { gte: 100 } } } }, + }), + ).toResolveWithLength(2); + + // $is with multiple sub-models → OR semantics (1 Video with viewCount>0 OR the Image) + await expect( + client.asset.findMany({ + where: { $is: { Video: { duration: { gt: 100 } }, Image: { format: 'png' } } }, + }), + ).toResolveWithLength(2); + + // $is on Video (which is itself a delegate) — filter on its sub-model RatedVideo + await expect( + client.video.findMany({ + where: { $is: { RatedVideo: { rating: 5 } } }, + }), + ).toResolveWithLength(1); + + // nested $is: Asset.$is.Video.$is.RatedVideo + await expect( + client.asset.findMany({ + where: { $is: { Video: { $is: { RatedVideo: { rating: 5 } } } } }, + }), + ).toResolveWithLength(1); + }); }); describe('Delegate update tests', () => { From d62333dc3bbac8b2732a963ee5c19e6d35ad016c Mon Sep 17 00:00:00 2001 From: motopods <58200641+motopods@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:45:29 +0800 Subject: [PATCH 5/7] $is filter uses camelCase keys and true for presence checks (#10) --- packages/orm/src/client/crud-types.ts | 8 +++-- .../src/client/crud/dialects/base-dialect.ts | 18 ++++++++--- packages/orm/src/client/zod/factory.ts | 8 +++-- tests/e2e/orm/client-api/delegate.test.ts | 31 +++++++------------ 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index e739575dd..f42ca93da 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -353,8 +353,8 @@ export type WhereInput< /** * Where filter that targets a specific sub-model of a delegate (polymorphic) base model. - * Keys are direct sub-model names; values are `WhereInput` for that sub-model. - * Multiple sub-model entries are combined with OR semantics. + * Keys are camelCase sub-model names; values are `true` (match any instance) or a `WhereInput` + * for that sub-model. Multiple sub-model entries are combined with OR semantics. */ export type SubModelWhereInput< Schema extends SchemaDef, @@ -362,7 +362,9 @@ export type SubModelWhereInput< Options extends QueryOptions = QueryOptions, ScalarOnly extends boolean = false, > = { - [SubModel in GetSubModels]?: WhereInput | null; + [SubModel in GetSubModels as Uncapitalize]?: + | true + | WhereInput; }; type FieldFilter< diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 3a7ddbf38..01503e59c 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -327,8 +327,8 @@ export abstract class BaseCrudDialect { /** * Builds a filter expression for the `$is` operator on a delegate (polymorphic) base model. - * Each key in `payload` is a direct sub-model name; the value is an optional `WhereInput` for - * that sub-model. Multiple sub-model entries are combined with OR semantics. + * Each key in `payload` is a camelCase sub-model name; the value is `true` (match any instance) + * or a `WhereInput` for that sub-model. Multiple sub-model entries are combined with OR semantics. */ private buildIsFilter(model: string, modelAlias: string, payload: Record): Expression { const discriminatorField = getDiscriminatorField(this.schema, model); @@ -345,11 +345,21 @@ export abstract class BaseCrudDialect { const conditions: Expression[] = []; - for (const [subModelName, subWhere] of Object.entries(payload)) { + for (const [subModelKey, subWhere] of Object.entries(payload)) { + // Map camelCase user-facing key back to PascalCase model name. ZenStack model names are + // always PascalCase (e.g. RatedVideo), so the camelCase key is simply the first character + // lowercased (e.g. ratedVideo). Uppercasing the first character recovers the original name. + const subModelName = subModelKey.charAt(0).toUpperCase() + subModelKey.slice(1); // discriminator must equal the sub-model name const discriminatorCheck = this.eb(discriminatorRef, '=', subModelName); - if (subWhere == null || (typeof subWhere === 'object' && Object.keys(subWhere).length === 0)) { + // `true`, null, or an empty object all mean "match any instance of this sub-model type" + const isMatchAny = + subWhere === true || + subWhere == null || + (typeof subWhere === 'object' && Object.keys(subWhere).length === 0); + + if (isMatchAny) { // no sub-model field filter — just check the discriminator conditions.push(discriminatorCheck); } else { diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 3db45a192..4735e0d91 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -490,10 +490,12 @@ export class ZodSchemaFactory< const subModelSchema = z.object( Object.fromEntries( modelDef.subModels.map((subModel) => [ - subModel, + subModel.charAt(0).toLowerCase() + subModel.slice(1), z - .lazy(() => this.makeWhereSchema(subModel, false, false, false, options)) - .nullish() + .union([ + z.literal(true), + z.lazy(() => this.makeWhereSchema(subModel, false, false, false, options)), + ]) .optional(), ]), ), diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index b4075a88c..4c8dc000a 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -564,59 +564,52 @@ describe('Delegate model tests ', () => { data: { format: 'png', viewCount: 2 }, }); - // $is: { Video: {} } — all assets that are Videos (2 RatedVideos) + // $is: { video: true } — all assets that are Videos (2 RatedVideos) await expect( client.asset.findMany({ - where: { $is: { Video: {} } }, + where: { $is: { video: true } }, }), ).toResolveWithLength(2); - // $is: { Video: null } — null value also means "is a Video" + // $is: { video: { duration: { gt: 100 } } } — only v2 await expect( client.asset.findMany({ - where: { $is: { Video: null } }, - }), - ).toResolveWithLength(2); - - // $is: { Video: { duration: { gt: 100 } } } — only v2 - await expect( - client.asset.findMany({ - where: { $is: { Video: { duration: { gt: 100 } } } }, + where: { $is: { video: { duration: { gt: 100 } } } }, }), ).toResolveWithLength(1); // $is combined with base-model field filter await expect( client.asset.findMany({ - where: { viewCount: { gt: 0 }, $is: { Video: { duration: { gt: 100 } } } }, + where: { viewCount: { gt: 0 }, $is: { video: { duration: { gt: 100 } } } }, }), ).toResolveWithLength(1); - // $is: { Video: { duration: { gte: 100 } } } — both videos + // $is: { video: { duration: { gte: 100 } } } — both videos await expect( client.asset.findMany({ - where: { $is: { Video: { duration: { gte: 100 } } } }, + where: { $is: { video: { duration: { gte: 100 } } } }, }), ).toResolveWithLength(2); // $is with multiple sub-models → OR semantics (1 Video with viewCount>0 OR the Image) await expect( client.asset.findMany({ - where: { $is: { Video: { duration: { gt: 100 } }, Image: { format: 'png' } } }, + where: { $is: { video: { duration: { gt: 100 } }, image: { format: 'png' } } }, }), ).toResolveWithLength(2); - // $is on Video (which is itself a delegate) — filter on its sub-model RatedVideo + // $is on Video (which is itself a delegate) — filter on its sub-model ratedVideo await expect( client.video.findMany({ - where: { $is: { RatedVideo: { rating: 5 } } }, + where: { $is: { ratedVideo: { rating: 5 } } }, }), ).toResolveWithLength(1); - // nested $is: Asset.$is.Video.$is.RatedVideo + // nested $is: Asset.$is.video.$is.ratedVideo await expect( client.asset.findMany({ - where: { $is: { Video: { $is: { RatedVideo: { rating: 5 } } } } }, + where: { $is: { video: { $is: { ratedVideo: { rating: 5 } } } } }, }), ).toResolveWithLength(1); }); From 07362ad1eea1b9cd4f9d5f8e4bc5e93e5a89b494 Mon Sep 17 00:00:00 2001 From: lucas Date: Tue, 7 Apr 2026 23:15:25 +0800 Subject: [PATCH 6/7] =?UTF-8?q?Add=20test=20cases=20covering=20`$is`=20fil?= =?UTF-8?q?ters=20where=20the=20filtered=20field=20is=20inherited=20from?= =?UTF-8?q?=20a=20delegate=20base=20model=20(e.g.=20`viewCount`=20on=20`As?= =?UTF-8?q?set`=20accessed=20via=20`$is:=20{=20video:=20{=20viewCount:=20.?= =?UTF-8?q?..=20}=20}`),=20including:=20-=20single=20inherited-field=20fil?= =?UTF-8?q?ter=20-=20mixed=20inherited=20+=20own-field=20filter=20-=20inhe?= =?UTF-8?q?rited-field=20filter=20returning=20no=20rows=20-=20nested=20del?= =?UTF-8?q?egate=20level=20(Video=20=E2=86=92=20RatedVideo)=20with=20inher?= =?UTF-8?q?ited=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/orm/client-api/delegate.test.ts | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index 4c8dc000a..1824485bd 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -612,6 +612,37 @@ describe('Delegate model tests ', () => { where: { $is: { video: { $is: { ratedVideo: { rating: 5 } } } } }, }), ).toResolveWithLength(1); + + // $is filtering on inherited (base-model) fields — viewCount lives on Asset, + // not on Video directly; this exercises the correlated-subquery path for + // fields where fieldDef.originModel !== subModelName. + await expect( + client.asset.findMany({ + where: { $is: { video: { viewCount: { gt: 0 } } } }, + }), + ).toResolveWithLength(1); // only v2 has viewCount=1, v1 has viewCount=0 + + // combine inherited-field filter with sub-model-own-field filter + await expect( + client.asset.findMany({ + where: { $is: { video: { viewCount: { gte: 0 }, duration: { gte: 100 } } } }, + }), + ).toResolveWithLength(2); // both videos match + + // inherited-field filter that matches nothing + await expect( + client.asset.findMany({ + where: { $is: { video: { viewCount: { gt: 10 } } } }, + }), + ).toResolveWithLength(0); + + // $is on nested delegate (Video → RatedVideo) filtering on Video's own + // inherited base field (viewCount from Asset) + await expect( + client.video.findMany({ + where: { $is: { ratedVideo: { viewCount: { gt: 0 } } } }, + }), + ).toResolveWithLength(1); // only v2 (viewCount=1, rating=4) }); }); From 89b2a45ba95b30b1ffe0273d2b11a45187eb26f7 Mon Sep 17 00:00:00 2001 From: lucas Date: Tue, 7 Apr 2026 23:41:29 +0800 Subject: [PATCH 7/7] Make $is reject unknown sub-model keys by zod factory. --- packages/orm/src/client/zod/factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 4735e0d91..316b07fa1 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -487,7 +487,7 @@ export class ZodSchemaFactory< // $is sub-model filter for delegate (polymorphic) base models const modelDef = requireModel(this.schema, model); if (modelDef.isDelegate && modelDef.subModels && modelDef.subModels.length > 0) { - const subModelSchema = z.object( + const subModelSchema = z.strictObject( Object.fromEntries( modelDef.subModels.map((subModel) => [ subModel.charAt(0).toLowerCase() + subModel.slice(1),