Skip to content

Commit 6c6890e

Browse files
authored
feat(validation): add API to suppress validation (#301)
* feat(validation): add API to suppress validation * fix "@@Validate" * fix * update
1 parent cad7098 commit 6c6890e

File tree

10 files changed

+407
-19
lines changed

10 files changed

+407
-19
lines changed

packages/runtime/src/client/client-impl.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@ export class ClientImpl<Schema extends SchemaDef> {
288288
return this.auth;
289289
}
290290

291+
$setInputValidation(enable: boolean) {
292+
const newOptions: ClientOptions<Schema> = {
293+
...this.options,
294+
validateInput: enable,
295+
};
296+
return new ClientImpl<Schema>(this.schema, newOptions, this);
297+
}
298+
291299
$executeRaw(query: TemplateStringsArray, ...values: any[]) {
292300
return createZenStackPromise(async () => {
293301
const result = await sql(query, ...values).execute(this.kysely);
@@ -325,7 +333,7 @@ export class ClientImpl<Schema extends SchemaDef> {
325333
}
326334

327335
function createClientProxy<Schema extends SchemaDef>(client: ClientImpl<Schema>): ClientImpl<Schema> {
328-
const inputValidator = new InputValidator(client.$schema);
336+
const inputValidator = new InputValidator(client as unknown as ClientContract<Schema>);
329337
const resultProcessor = new ResultProcessor(client.$schema, client.$options);
330338

331339
return new Proxy(client, {

packages/runtime/src/client/contract.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ export type ClientContract<Schema extends SchemaDef> = {
103103
*/
104104
$setAuth(auth: AuthType<Schema> | undefined): ClientContract<Schema>;
105105

106+
/**
107+
* Returns a new client enabling/disabling input validations expressed with attributes like
108+
* `@email`, `@regex`, `@@validate`, etc.
109+
*/
110+
$setInputValidation(enable: boolean): ClientContract<Schema>;
111+
106112
/**
107113
* The Kysely query builder instance.
108114
*/

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

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { enumerate } from '../../../utils/enumerate';
1616
import { extractFields } from '../../../utils/object-utils';
1717
import { formatError } from '../../../utils/zod-utils';
1818
import { AGGREGATE_OPERATORS, LOGICAL_COMBINATORS, NUMERIC_FIELD_TYPES } from '../../constants';
19+
import type { ClientContract } from '../../contract';
1920
import {
2021
type AggregateArgs,
2122
type CountArgs,
@@ -53,7 +54,15 @@ type GetSchemaFunc<Schema extends SchemaDef, Options> = (model: GetModels<Schema
5354
export class InputValidator<Schema extends SchemaDef> {
5455
private schemaCache = new Map<string, ZodType>();
5556

56-
constructor(private readonly schema: Schema) {}
57+
constructor(private readonly client: ClientContract<Schema>) {}
58+
59+
private get schema() {
60+
return this.client.$schema;
61+
}
62+
63+
private get extraValidationsEnabled() {
64+
return this.client.$options.validateInput !== false;
65+
}
5766

5867
validateFindArgs(model: GetModels<Schema>, args: unknown, options: { unique: boolean; findOne: boolean }) {
5968
return this.validate<
@@ -251,23 +260,31 @@ export class InputValidator<Schema extends SchemaDef> {
251260
return this.makeTypeDefSchema(type);
252261
} else {
253262
return match(type)
254-
.with('String', () => addStringValidation(z.string(), attributes))
255-
.with('Int', () => addNumberValidation(z.number().int(), attributes))
256-
.with('Float', () => addNumberValidation(z.number(), attributes))
263+
.with('String', () =>
264+
this.extraValidationsEnabled ? addStringValidation(z.string(), attributes) : z.string(),
265+
)
266+
.with('Int', () =>
267+
this.extraValidationsEnabled ? addNumberValidation(z.number().int(), attributes) : z.number().int(),
268+
)
269+
.with('Float', () =>
270+
this.extraValidationsEnabled ? addNumberValidation(z.number(), attributes) : z.number(),
271+
)
257272
.with('Boolean', () => z.boolean())
258273
.with('BigInt', () =>
259274
z.union([
260-
addNumberValidation(z.number().int(), attributes),
261-
addBigIntValidation(z.bigint(), attributes),
262-
]),
263-
)
264-
.with('Decimal', () =>
265-
z.union([
266-
addNumberValidation(z.number(), attributes),
267-
addDecimalValidation(z.instanceof(Decimal), attributes),
268-
addDecimalValidation(z.string(), attributes),
275+
this.extraValidationsEnabled
276+
? addNumberValidation(z.number().int(), attributes)
277+
: z.number().int(),
278+
this.extraValidationsEnabled ? addBigIntValidation(z.bigint(), attributes) : z.bigint(),
269279
]),
270280
)
281+
.with('Decimal', () => {
282+
return z.union([
283+
this.extraValidationsEnabled ? addNumberValidation(z.number(), attributes) : z.number(),
284+
addDecimalValidation(z.instanceof(Decimal), attributes, this.extraValidationsEnabled),
285+
addDecimalValidation(z.string(), attributes, this.extraValidationsEnabled),
286+
]);
287+
})
271288
.with('DateTime', () => z.union([z.date(), z.string().datetime()]))
272289
.with('Bytes', () => z.instanceof(Uint8Array))
273290
.otherwise(() => z.unknown());
@@ -913,8 +930,12 @@ export class InputValidator<Schema extends SchemaDef> {
913930
}
914931
});
915932

916-
const uncheckedCreateSchema = addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes);
917-
const checkedCreateSchema = addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes);
933+
const uncheckedCreateSchema = this.extraValidationsEnabled
934+
? addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes)
935+
: z.strictObject(uncheckedVariantFields);
936+
const checkedCreateSchema = this.extraValidationsEnabled
937+
? addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes)
938+
: z.strictObject(checkedVariantFields);
918939

919940
if (!hasRelation) {
920941
return this.orArray(uncheckedCreateSchema, canBeArray);
@@ -1193,8 +1214,12 @@ export class InputValidator<Schema extends SchemaDef> {
11931214
}
11941215
});
11951216

1196-
const uncheckedUpdateSchema = addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes);
1197-
const checkedUpdateSchema = addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes);
1217+
const uncheckedUpdateSchema = this.extraValidationsEnabled
1218+
? addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes)
1219+
: z.strictObject(uncheckedVariantFields);
1220+
const checkedUpdateSchema = this.extraValidationsEnabled
1221+
? addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes)
1222+
: z.strictObject(checkedVariantFields);
11981223
if (!hasRelation) {
11991224
return uncheckedUpdateSchema;
12001225
} else {

packages/runtime/src/client/crud/validator/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export function addBigIntValidation(schema: z.ZodBigInt, attributes: AttributeAp
145145
export function addDecimalValidation(
146146
schema: z.ZodType<Decimal> | z.ZodString,
147147
attributes: AttributeApplication[] | undefined,
148+
addExtraValidation: boolean,
148149
): z.ZodSchema {
149150
let result: z.ZodSchema = schema;
150151

@@ -176,7 +177,7 @@ export function addDecimalValidation(
176177
});
177178
}
178179

179-
if (attributes) {
180+
if (attributes && addExtraValidation) {
180181
for (const attr of attributes) {
181182
const val = getArgValue<number>(attr.args?.[0]?.value);
182183
if (val === undefined) {

packages/runtime/src/client/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ export type ClientOptions<Schema extends SchemaDef> = {
7272
* @see https://github.com/brianc/node-postgres/issues/429
7373
*/
7474
fixPostgresTimezone?: boolean;
75+
76+
/**
77+
* Whether to enable input validations expressed with attributes like `@email`, `@regex`,
78+
* `@@validate`, etc. Defaults to `true`.
79+
*/
80+
validateInput?: boolean;
7581
} & (HasComputedFields<Schema> extends true
7682
? {
7783
/**

tests/e2e/orm/validation/custom-validation.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,66 @@ describe('Custom validation tests', () => {
108108
).toResolveTruthy();
109109
}
110110
});
111+
112+
it('allows disabling validation', async () => {
113+
const db = await createTestClient(
114+
`
115+
model User {
116+
id Int @id @default(autoincrement())
117+
email String @unique @email
118+
@@validate(length(email, 8))
119+
@@allow('all', true)
120+
}
121+
`,
122+
);
123+
124+
await expect(
125+
db.user.create({
126+
data: {
127+
email: 'xyz',
128+
},
129+
}),
130+
).toBeRejectedByValidation();
131+
await expect(
132+
db.user.create({
133+
data: {
134+
135+
},
136+
}),
137+
).toBeRejectedByValidation();
138+
139+
await expect(
140+
db.$setInputValidation(false).user.create({
141+
data: {
142+
id: 1,
143+
email: 'xyz',
144+
},
145+
}),
146+
).toResolveTruthy();
147+
148+
await expect(
149+
db.$setInputValidation(false).user.update({
150+
where: { id: 1 },
151+
data: {
152+
153+
},
154+
}),
155+
).toResolveTruthy();
156+
157+
// original client not affected
158+
await expect(
159+
db.user.create({
160+
data: {
161+
email: 'xyz',
162+
},
163+
}),
164+
).toBeRejectedByValidation();
165+
await expect(
166+
db.user.create({
167+
data: {
168+
169+
},
170+
}),
171+
).toBeRejectedByValidation();
172+
});
111173
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createPolicyTestClient } from '@zenstackhq/testtools';
2+
import { expect, it } from 'vitest';
3+
4+
// TODO: field-level policy support
5+
it.skip('verifies issue 2000', async () => {
6+
const db = await createPolicyTestClient(
7+
`
8+
type Base {
9+
id String @id @default(uuid()) @deny('update', true)
10+
createdAt DateTime @default(now()) @deny('update', true)
11+
updatedAt DateTime @updatedAt @deny('update', true)
12+
active Boolean @default(false)
13+
published Boolean @default(true)
14+
deleted Boolean @default(false)
15+
startDate DateTime?
16+
endDate DateTime?
17+
18+
@@allow('create', true)
19+
@@allow('read', true)
20+
@@allow('update', true)
21+
}
22+
23+
enum EntityType {
24+
User
25+
Alias
26+
Group
27+
Service
28+
Device
29+
Organization
30+
Guest
31+
}
32+
33+
model Entity with Base {
34+
entityType EntityType
35+
name String? @unique
36+
members Entity[] @relation("members")
37+
memberOf Entity[] @relation("members")
38+
@@delegate(entityType)
39+
40+
41+
@@allow('create', true)
42+
@@allow('read', true)
43+
@@allow('update', true)
44+
@@validate(!active || (active && name != null), "Active Entities Must Have A Name")
45+
}
46+
47+
model User extends Entity {
48+
profile Json?
49+
username String @unique
50+
password String @password
51+
52+
@@allow('create', true)
53+
@@allow('read', true)
54+
@@allow('update', true)
55+
}
56+
`,
57+
);
58+
59+
await expect(db.user.create({ data: { username: 'admin', password: 'abc12345' } })).toResolveTruthy();
60+
await expect(
61+
db.user.update({ where: { username: 'admin' }, data: { password: 'abc123456789123' } }),
62+
).toResolveTruthy();
63+
64+
// violating validation rules
65+
await expect(db.user.update({ where: { username: 'admin' }, data: { active: true } })).toBeRejectedByPolicy();
66+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createPolicyTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
// TODO: field-level policy support
5+
describe.skip('Regression for issue 2007', () => {
6+
it('regression1', async () => {
7+
const db = await createPolicyTestClient(
8+
`
9+
model Page {
10+
id String @id @default(cuid())
11+
title String
12+
13+
images Image[]
14+
15+
@@allow('all', true)
16+
}
17+
18+
model Image {
19+
id String @id @default(cuid()) @deny('update', true)
20+
url String
21+
pageId String?
22+
page Page? @relation(fields: [pageId], references: [id])
23+
24+
@@allow('all', true)
25+
}
26+
`,
27+
);
28+
29+
const image = await db.image.create({
30+
data: {
31+
url: 'https://example.com/image.png',
32+
},
33+
});
34+
35+
await expect(
36+
db.image.update({
37+
where: { id: image.id },
38+
data: {
39+
page: {
40+
create: {
41+
title: 'Page 1',
42+
},
43+
},
44+
},
45+
}),
46+
).toResolveTruthy();
47+
});
48+
49+
it('regression2', async () => {
50+
const db = await createPolicyTestClient(
51+
`
52+
model Page {
53+
id String @id @default(cuid())
54+
title String
55+
56+
images Image[]
57+
58+
@@allow('all', true)
59+
}
60+
61+
model Image {
62+
id String @id @default(cuid())
63+
url String
64+
pageId String? @deny('update', true)
65+
page Page? @relation(fields: [pageId], references: [id])
66+
67+
@@allow('all', true)
68+
}
69+
`,
70+
);
71+
72+
const image = await db.image.create({
73+
data: {
74+
url: 'https://example.com/image.png',
75+
},
76+
});
77+
78+
await expect(
79+
db.image.update({
80+
where: { id: image.id },
81+
data: {
82+
page: {
83+
create: {
84+
title: 'Page 1',
85+
},
86+
},
87+
},
88+
}),
89+
).toBeRejectedByPolicy();
90+
});
91+
});

0 commit comments

Comments
 (0)