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
18 changes: 18 additions & 0 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,24 @@ export type WhereInput<
AND?: OrArray<WhereInput<Schema, Model, Options, ScalarOnly>>;
OR?: WhereInput<Schema, Model, Options, ScalarOnly>[];
NOT?: OrArray<WhereInput<Schema, Model, Options, ScalarOnly>>;
} & (IsDelegateModel<Schema, Model> extends true
? { $is?: SubModelWhereInput<Schema, Model, Options, ScalarOnly> }
: object);

/**
* Where filter that targets a specific sub-model of a delegate (polymorphic) base model.
* 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,
Model extends GetModels<Schema>,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
ScalarOnly extends boolean = false,
> = {
[SubModel in GetSubModels<Schema, Model> as Uncapitalize<SubModel & string>]?:
| true
| WhereInput<Schema, SubModel, Options, ScalarOnly>;
};

type FieldFilter<
Expand Down
69 changes: 69 additions & 0 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ensureArray,
flattenCompoundUniqueFilters,
getDelegateDescendantModels,
getDiscriminatorField,
getManyToManyRelation,
getModelFields,
getRelationForeignKeyFieldPairs,
Expand Down Expand Up @@ -224,6 +225,11 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
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<string, any>));
}

return result;
}

Expand Down Expand Up @@ -319,6 +325,69 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
.exhaustive();
}

/**
* Builds a filter expression for the `$is` operator on a delegate (polymorphic) base model.
* 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<string, any>): Expression<SqlBool> {
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<SqlBool>[] = [];

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);

// `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 {
// 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);
Expand Down
19 changes: 19 additions & 0 deletions packages/orm/src/client/zod/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,25 @@ 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.strictObject(
Object.fromEntries(
modelDef.subModels.map((subModel) => [
subModel.charAt(0).toLowerCase() + subModel.slice(1),
z
.union([
z.literal(true),
z.lazy(() => this.makeWhereSchema(subModel, false, false, false, options)),
])
.optional(),
]),
),
);
fields['$is'] = subModelSchema.optional();
}

// logical operators
fields['AND'] = this.orArray(
z.lazy(() => this.makeWhereSchema(model, false, withoutRelationFields, false, options)),
Expand Down
87 changes: 87 additions & 0 deletions tests/e2e/orm/client-api/delegate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,93 @@ 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: true } — all assets that are Videos (2 RatedVideos)
await expect(
client.asset.findMany({
where: { $is: { video: true } },
}),
).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);

// $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)
});
});

describe('Delegate update tests', () => {
Expand Down
Loading