Skip to content

Commit 681ef25

Browse files
committed
fix: direct field value filter should be controlled by filter slicing
1 parent fece8e9 commit 681ef25

3 files changed

Lines changed: 175 additions & 45 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ type EnumFilter<
345345
WithAggregations extends boolean,
346346
AllowedKinds extends FilterKind,
347347
> =
348-
| NullableIf<keyof GetEnum<Schema, T>, Nullable>
348+
| ('Equality' extends AllowedKinds ? NullableIf<keyof GetEnum<Schema, T>, Nullable> : never)
349349
| (('Equality' extends AllowedKinds
350350
? {
351351
/**
@@ -510,7 +510,7 @@ export type StringFilter<
510510
WithAggregations extends boolean,
511511
AllowedKinds extends FilterKind = FilterKind,
512512
> =
513-
| NullableIf<string, Nullable>
513+
| ('Equality' extends AllowedKinds ? NullableIf<string, Nullable> : never)
514514
| (CommonPrimitiveFilter<string, 'String', Nullable, WithAggregations, AllowedKinds> &
515515
('Like' extends AllowedKinds
516516
? {
@@ -560,7 +560,7 @@ export type NumberFilter<
560560
WithAggregations extends boolean,
561561
AllowedKinds extends FilterKind = FilterKind,
562562
> =
563-
| NullableIf<number | bigint, Nullable>
563+
| ('Equality' extends AllowedKinds ? NullableIf<number | bigint, Nullable> : never)
564564
| (CommonPrimitiveFilter<number, T, Nullable, WithAggregations, AllowedKinds> &
565565
(WithAggregations extends true
566566
? {
@@ -596,7 +596,7 @@ export type DateTimeFilter<
596596
WithAggregations extends boolean,
597597
AllowedKinds extends FilterKind = FilterKind,
598598
> =
599-
| NullableIf<Date | string, Nullable>
599+
| ('Equality' extends AllowedKinds ? NullableIf<Date | string, Nullable> : never)
600600
| (CommonPrimitiveFilter<Date | string, 'DateTime', Nullable, WithAggregations, AllowedKinds> &
601601
(WithAggregations extends true
602602
? {
@@ -622,7 +622,7 @@ export type BytesFilter<
622622
WithAggregations extends boolean,
623623
AllowedKinds extends FilterKind = FilterKind,
624624
> =
625-
| NullableIf<Uint8Array | Buffer, Nullable>
625+
| ('Equality' extends AllowedKinds ? NullableIf<Uint8Array | Buffer, Nullable> : never)
626626
| (('Equality' extends AllowedKinds
627627
? {
628628
/**
@@ -670,7 +670,7 @@ export type BooleanFilter<
670670
WithAggregations extends boolean,
671671
AllowedKinds extends FilterKind = FilterKind,
672672
> =
673-
| NullableIf<boolean, Nullable>
673+
| ('Equality' extends AllowedKinds ? NullableIf<boolean, Nullable> : never)
674674
| (('Equality' extends AllowedKinds
675675
? {
676676
/**

packages/orm/src/client/crud/validator/index.ts

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,17 @@ export class InputValidator<Schema extends SchemaDef> {
559559
): ZodType {
560560
const modelDef = requireModel(this.schema, model);
561561

562+
// unique field used in unique filters bypass filter slicing
563+
const uniqueFieldNames = unique
564+
? getUniqueFields(this.schema, model)
565+
.filter(
566+
(uf): uf is { name: string; def: FieldDef } =>
567+
// single-field unique
568+
'def' in uf,
569+
)
570+
.map((uf) => uf.name)
571+
: undefined;
572+
562573
const fields: Record<string, any> = {};
563574
for (const field of Object.keys(modelDef.fields)) {
564575
const fieldDef = requireField(this.schema, model, field);
@@ -602,11 +613,13 @@ export class InputValidator<Schema extends SchemaDef> {
602613
}
603614
}
604615
} else {
616+
const ignoreSlicing = !!uniqueFieldNames?.includes(field);
617+
605618
const enumDef = getEnum(this.schema, fieldDef.type);
606619
if (enumDef) {
607620
// enum
608621
if (Object.keys(enumDef.values).length > 0) {
609-
fieldSchema = this.makeEnumFilterSchema(model, fieldDef, withAggregations);
622+
fieldSchema = this.makeEnumFilterSchema(model, fieldDef, withAggregations, ignoreSlicing);
610623
}
611624
} else if (fieldDef.array) {
612625
// array field
@@ -615,7 +628,7 @@ export class InputValidator<Schema extends SchemaDef> {
615628
fieldSchema = this.makeTypedJsonFilterSchema(model, fieldDef);
616629
} else {
617630
// primitive field
618-
fieldSchema = this.makePrimitiveFilterSchema(model, fieldDef, withAggregations);
631+
fieldSchema = this.makePrimitiveFilterSchema(model, fieldDef, withAggregations, ignoreSlicing);
619632
}
620633
}
621634

@@ -626,6 +639,7 @@ export class InputValidator<Schema extends SchemaDef> {
626639

627640
if (unique) {
628641
// add compound unique fields, e.g. `{ id1_id2: { id1: 1, id2: 1 } }`
642+
// compound-field filters are not affected by slicing
629643
const uniqueFields = getUniqueFields(this.schema, model);
630644
for (const uniqueField of uniqueFields) {
631645
if ('defs' in uniqueField) {
@@ -639,13 +653,12 @@ export class InputValidator<Schema extends SchemaDef> {
639653
if (enumDef) {
640654
// enum
641655
if (Object.keys(enumDef.values).length > 0) {
642-
fieldSchema = this.makeEnumFilterSchema(model, def, false);
656+
fieldSchema = this.makeEnumFilterSchema(model, def, false, true);
643657
} else {
644658
fieldSchema = z.never();
645659
}
646660
} else {
647-
// regular field
648-
fieldSchema = this.makePrimitiveFilterSchema(model, def, false);
661+
fieldSchema = this.makePrimitiveFilterSchema(model, def, false, true);
649662
}
650663
return [key, fieldSchema];
651664
}),
@@ -776,7 +789,12 @@ export class InputValidator<Schema extends SchemaDef> {
776789
}
777790

778791
@cache()
779-
private makeEnumFilterSchema(model: string, fieldInfo: FieldInfo, withAggregations: boolean) {
792+
private makeEnumFilterSchema(
793+
model: string,
794+
fieldInfo: FieldInfo,
795+
withAggregations: boolean,
796+
ignoreSlicing: boolean = false,
797+
) {
780798
const enumName = fieldInfo.type;
781799
const optional = !!fieldInfo.optional;
782800
const array = !!fieldInfo.array;
@@ -787,7 +805,7 @@ export class InputValidator<Schema extends SchemaDef> {
787805
if (array) {
788806
return this.internalMakeArrayFilterSchema(model, fieldInfo.name, baseSchema);
789807
}
790-
const allowedFilterKinds = this.getEffectiveFilterKinds(model, fieldInfo.name);
808+
const allowedFilterKinds = ignoreSlicing ? undefined : this.getEffectiveFilterKinds(model, fieldInfo.name);
791809
const components = this.makeCommonPrimitiveFilterComponents(
792810
baseSchema,
793811
optional,
@@ -797,12 +815,7 @@ export class InputValidator<Schema extends SchemaDef> {
797815
allowedFilterKinds,
798816
);
799817

800-
// If all filter operators are excluded, return z.never()
801-
if (Object.keys(components).length === 0) {
802-
return z.never();
803-
}
804-
805-
return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]);
818+
return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds);
806819
}
807820

808821
@cache()
@@ -831,8 +844,13 @@ export class InputValidator<Schema extends SchemaDef> {
831844
}
832845

833846
@cache()
834-
private makePrimitiveFilterSchema(model: string, fieldInfo: FieldInfo, withAggregations: boolean) {
835-
const allowedFilterKinds = this.getEffectiveFilterKinds(model, fieldInfo.name);
847+
private makePrimitiveFilterSchema(
848+
model: string,
849+
fieldInfo: FieldInfo,
850+
withAggregations: boolean,
851+
ignoreSlicing = false,
852+
) {
853+
const allowedFilterKinds = ignoreSlicing ? undefined : this.getEffectiveFilterKinds(model, fieldInfo.name);
836854
const type = fieldInfo.type as BuiltinType;
837855
const optional = !!fieldInfo.optional;
838856
return match(type)
@@ -935,12 +953,7 @@ export class InputValidator<Schema extends SchemaDef> {
935953
allowedFilterKinds,
936954
);
937955

938-
// If all filter operators are excluded, return z.never()
939-
if (Object.keys(components).length === 0) {
940-
return z.never();
941-
}
942-
943-
return z.union([this.nullableIf(z.boolean(), optional), z.strictObject(components)]);
956+
return this.createUnionFilterSchema(z.boolean(), optional, components, allowedFilterKinds);
944957
}
945958

946959
@cache()
@@ -959,12 +972,7 @@ export class InputValidator<Schema extends SchemaDef> {
959972
allowedFilterKinds,
960973
);
961974

962-
// If all filter operators are excluded, return z.never()
963-
if (Object.keys(components).length === 0) {
964-
return z.never();
965-
}
966-
967-
return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]);
975+
return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds);
968976
}
969977

970978
private makeCommonPrimitiveFilterComponents(
@@ -1022,12 +1030,7 @@ export class InputValidator<Schema extends SchemaDef> {
10221030
allowedFilterKinds,
10231031
);
10241032

1025-
// If all filter operators are excluded, return z.never()
1026-
if (Object.keys(components).length === 0) {
1027-
return z.never();
1028-
}
1029-
1030-
return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]);
1033+
return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds);
10311034
}
10321035

10331036
private makeNumberFilterSchema(
@@ -1078,12 +1081,7 @@ export class InputValidator<Schema extends SchemaDef> {
10781081
...filteredStringOperators,
10791082
};
10801083

1081-
// If all filter operators are excluded, return z.never()
1082-
if (Object.keys(allComponents).length === 0) {
1083-
return z.never();
1084-
}
1085-
1086-
return z.union([this.nullableIf(z.string(), optional), z.strictObject(allComponents)]);
1084+
return this.createUnionFilterSchema(z.string(), optional, allComponents, allowedFilterKinds);
10871085
}
10881086

10891087
private makeStringModeSchema() {
@@ -2173,6 +2171,31 @@ export class InputValidator<Schema extends SchemaDef> {
21732171
) as Partial<T>;
21742172
}
21752173

2174+
private createUnionFilterSchema(
2175+
valueSchema: ZodType,
2176+
optional: boolean,
2177+
components: Record<string, ZodType>,
2178+
allowedFilterKinds: Set<string> | undefined,
2179+
) {
2180+
// If all filter operators are excluded
2181+
if (Object.keys(components).length === 0) {
2182+
// if equality filters are allowed, allow direct value
2183+
if (!allowedFilterKinds || allowedFilterKinds.has('Equality')) {
2184+
return this.nullableIf(valueSchema, optional);
2185+
}
2186+
// otherwise nothing is allowed
2187+
return z.never();
2188+
}
2189+
2190+
if (!allowedFilterKinds || allowedFilterKinds.has('Equality')) {
2191+
// direct value or filter operators
2192+
return z.union([this.nullableIf(valueSchema, optional), z.strictObject(components)]);
2193+
} else {
2194+
// filter operators
2195+
return z.strictObject(components);
2196+
}
2197+
}
2198+
21762199
/**
21772200
* Checks if a model is included in the slicing configuration.
21782201
* Returns true if the model is allowed, false if it's excluded.

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,5 +1761,112 @@ describe('Query slicing tests', () => {
17611761
).toBeRejectedByValidation(['"equals"']);
17621762
});
17631763
});
1764+
1765+
describe('Direct value filter slicing', () => {
1766+
it('allows direct value filters when Equality kind is included', async () => {
1767+
const options = {
1768+
slicing: {
1769+
models: {
1770+
User: {
1771+
fields: {
1772+
$all: {
1773+
includedFilterKinds: ['Equality'] as const,
1774+
},
1775+
},
1776+
},
1777+
},
1778+
},
1779+
dialect: {} as any,
1780+
} as const;
1781+
1782+
const db = await createTestClient<typeof schema, typeof options>(schema, options);
1783+
1784+
await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } });
1785+
1786+
const user = await db.user.findFirst({
1787+
where: { email: 'test@example.com' },
1788+
});
1789+
expect(user?.email).toBe('test@example.com');
1790+
});
1791+
1792+
it('rejects direct value filters when Equality kind is excluded', async () => {
1793+
const options = {
1794+
slicing: {
1795+
models: {
1796+
User: {
1797+
fields: {
1798+
$all: {
1799+
includedFilterKinds: ['Range'] as const,
1800+
},
1801+
},
1802+
},
1803+
},
1804+
},
1805+
dialect: {} as any,
1806+
} as const;
1807+
1808+
const db = await createTestClient<typeof schema, typeof options>(schema, options);
1809+
1810+
await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } });
1811+
1812+
await expect(
1813+
db.user.findFirst({
1814+
// @ts-expect-error - direct value shorthand maps to Equality filters
1815+
where: { email: 'test@example.com' },
1816+
}),
1817+
).toBeRejectedByValidation(['"where.email"']);
1818+
});
1819+
1820+
it('still allows unique operations to use direct value filters', async () => {
1821+
const options = {
1822+
slicing: {
1823+
models: {
1824+
User: {
1825+
fields: {
1826+
$all: {
1827+
includedFilterKinds: ['Range'] as const,
1828+
},
1829+
},
1830+
},
1831+
},
1832+
},
1833+
dialect: {} as any,
1834+
} as const;
1835+
1836+
const db = await createTestClient<typeof schema, typeof options>(schema, options);
1837+
1838+
await db.user.create({ data: { email: 'unique@example.com', name: 'Original Name' } });
1839+
1840+
await expect(
1841+
db.user.findMany({
1842+
// @ts-expect-error - findMany cannot use direct value filters without Equality kind
1843+
where: { email: 'unique@example.com' },
1844+
}),
1845+
).toBeRejectedByValidation(['"where.email"']);
1846+
1847+
const uniqueUser = await db.user.findUnique({
1848+
where: { email: 'unique@example.com' },
1849+
});
1850+
expect(uniqueUser?.name).toBe('Original Name');
1851+
1852+
await expect(
1853+
db.user.findUnique({
1854+
// @ts-expect-error non-unique fields are still sliced
1855+
where: { email: 'unique@example.com', age: 10 },
1856+
}),
1857+
).toBeRejectedByValidation(['"where.age"']);
1858+
1859+
const updated = await db.user.update({
1860+
where: { email: 'unique@example.com' },
1861+
data: { name: 'Updated Name' },
1862+
});
1863+
expect(updated.name).toBe('Updated Name');
1864+
1865+
const deleted = await db.user.delete({
1866+
where: { email: 'unique@example.com' },
1867+
});
1868+
expect(deleted.email).toBe('unique@example.com');
1869+
});
1870+
});
17641871
});
17651872
});

0 commit comments

Comments
 (0)