Skip to content

Commit d62333d

Browse files
authored
$is filter uses camelCase keys and true for presence checks (#10)
1 parent 252192d commit d62333d

File tree

4 files changed

+36
-29
lines changed

4 files changed

+36
-29
lines changed

packages/orm/src/client/crud-types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,16 +353,18 @@ export type WhereInput<
353353

354354
/**
355355
* Where filter that targets a specific sub-model of a delegate (polymorphic) base model.
356-
* Keys are direct sub-model names; values are `WhereInput` for that sub-model.
357-
* Multiple sub-model entries are combined with OR semantics.
356+
* Keys are camelCase sub-model names; values are `true` (match any instance) or a `WhereInput`
357+
* for that sub-model. Multiple sub-model entries are combined with OR semantics.
358358
*/
359359
export type SubModelWhereInput<
360360
Schema extends SchemaDef,
361361
Model extends GetModels<Schema>,
362362
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
363363
ScalarOnly extends boolean = false,
364364
> = {
365-
[SubModel in GetSubModels<Schema, Model>]?: WhereInput<Schema, SubModel, Options, ScalarOnly> | null;
365+
[SubModel in GetSubModels<Schema, Model> as Uncapitalize<SubModel & string>]?:
366+
| true
367+
| WhereInput<Schema, SubModel, Options, ScalarOnly>;
366368
};
367369

368370
type FieldFilter<

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,8 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
327327

328328
/**
329329
* Builds a filter expression for the `$is` operator on a delegate (polymorphic) base model.
330-
* Each key in `payload` is a direct sub-model name; the value is an optional `WhereInput` for
331-
* that sub-model. Multiple sub-model entries are combined with OR semantics.
330+
* Each key in `payload` is a camelCase sub-model name; the value is `true` (match any instance)
331+
* or a `WhereInput` for that sub-model. Multiple sub-model entries are combined with OR semantics.
332332
*/
333333
private buildIsFilter(model: string, modelAlias: string, payload: Record<string, any>): Expression<SqlBool> {
334334
const discriminatorField = getDiscriminatorField(this.schema, model);
@@ -345,11 +345,21 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
345345

346346
const conditions: Expression<SqlBool>[] = [];
347347

348-
for (const [subModelName, subWhere] of Object.entries(payload)) {
348+
for (const [subModelKey, subWhere] of Object.entries(payload)) {
349+
// Map camelCase user-facing key back to PascalCase model name. ZenStack model names are
350+
// always PascalCase (e.g. RatedVideo), so the camelCase key is simply the first character
351+
// lowercased (e.g. ratedVideo). Uppercasing the first character recovers the original name.
352+
const subModelName = subModelKey.charAt(0).toUpperCase() + subModelKey.slice(1);
349353
// discriminator must equal the sub-model name
350354
const discriminatorCheck = this.eb(discriminatorRef, '=', subModelName);
351355

352-
if (subWhere == null || (typeof subWhere === 'object' && Object.keys(subWhere).length === 0)) {
356+
// `true`, null, or an empty object all mean "match any instance of this sub-model type"
357+
const isMatchAny =
358+
subWhere === true ||
359+
subWhere == null ||
360+
(typeof subWhere === 'object' && Object.keys(subWhere).length === 0);
361+
362+
if (isMatchAny) {
353363
// no sub-model field filter — just check the discriminator
354364
conditions.push(discriminatorCheck);
355365
} else {

packages/orm/src/client/zod/factory.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,10 +490,12 @@ export class ZodSchemaFactory<
490490
const subModelSchema = z.object(
491491
Object.fromEntries(
492492
modelDef.subModels.map((subModel) => [
493-
subModel,
493+
subModel.charAt(0).toLowerCase() + subModel.slice(1),
494494
z
495-
.lazy(() => this.makeWhereSchema(subModel, false, false, false, options))
496-
.nullish()
495+
.union([
496+
z.literal(true),
497+
z.lazy(() => this.makeWhereSchema(subModel, false, false, false, options)),
498+
])
497499
.optional(),
498500
]),
499501
),

tests/e2e/orm/client-api/delegate.test.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -564,59 +564,52 @@ describe('Delegate model tests ', () => {
564564
data: { format: 'png', viewCount: 2 },
565565
});
566566

567-
// $is: { Video: {} } — all assets that are Videos (2 RatedVideos)
567+
// $is: { video: true } — all assets that are Videos (2 RatedVideos)
568568
await expect(
569569
client.asset.findMany({
570-
where: { $is: { Video: {} } },
570+
where: { $is: { video: true } },
571571
}),
572572
).toResolveWithLength(2);
573573

574-
// $is: { Video: null } — null value also means "is a Video"
574+
// $is: { video: { duration: { gt: 100 } } } — only v2
575575
await expect(
576576
client.asset.findMany({
577-
where: { $is: { Video: null } },
578-
}),
579-
).toResolveWithLength(2);
580-
581-
// $is: { Video: { duration: { gt: 100 } } } — only v2
582-
await expect(
583-
client.asset.findMany({
584-
where: { $is: { Video: { duration: { gt: 100 } } } },
577+
where: { $is: { video: { duration: { gt: 100 } } } },
585578
}),
586579
).toResolveWithLength(1);
587580

588581
// $is combined with base-model field filter
589582
await expect(
590583
client.asset.findMany({
591-
where: { viewCount: { gt: 0 }, $is: { Video: { duration: { gt: 100 } } } },
584+
where: { viewCount: { gt: 0 }, $is: { video: { duration: { gt: 100 } } } },
592585
}),
593586
).toResolveWithLength(1);
594587

595-
// $is: { Video: { duration: { gte: 100 } } } — both videos
588+
// $is: { video: { duration: { gte: 100 } } } — both videos
596589
await expect(
597590
client.asset.findMany({
598-
where: { $is: { Video: { duration: { gte: 100 } } } },
591+
where: { $is: { video: { duration: { gte: 100 } } } },
599592
}),
600593
).toResolveWithLength(2);
601594

602595
// $is with multiple sub-models → OR semantics (1 Video with viewCount>0 OR the Image)
603596
await expect(
604597
client.asset.findMany({
605-
where: { $is: { Video: { duration: { gt: 100 } }, Image: { format: 'png' } } },
598+
where: { $is: { video: { duration: { gt: 100 } }, image: { format: 'png' } } },
606599
}),
607600
).toResolveWithLength(2);
608601

609-
// $is on Video (which is itself a delegate) — filter on its sub-model RatedVideo
602+
// $is on Video (which is itself a delegate) — filter on its sub-model ratedVideo
610603
await expect(
611604
client.video.findMany({
612-
where: { $is: { RatedVideo: { rating: 5 } } },
605+
where: { $is: { ratedVideo: { rating: 5 } } },
613606
}),
614607
).toResolveWithLength(1);
615608

616-
// nested $is: Asset.$is.Video.$is.RatedVideo
609+
// nested $is: Asset.$is.video.$is.ratedVideo
617610
await expect(
618611
client.asset.findMany({
619-
where: { $is: { Video: { $is: { RatedVideo: { rating: 5 } } } } },
612+
where: { $is: { video: { $is: { ratedVideo: { rating: 5 } } } } },
620613
}),
621614
).toResolveWithLength(1);
622615
});

0 commit comments

Comments
 (0)