Skip to content

Commit bf6c9a4

Browse files
ymc9claudeclaude[bot]
authored
refactor(orm): move validateInput logic into InputValidator (#2480)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Yiming Cao <ymc9@users.noreply.github.com>
1 parent d4fbb38 commit bf6c9a4

File tree

6 files changed

+131
-15
lines changed

6 files changed

+131
-15
lines changed

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ export class ClientImpl {
120120
}
121121

122122
this.kysely = new Kysely(this.kyselyProps);
123-
this.inputValidator = baseClient?.inputValidator ?? new InputValidator(this as any);
123+
this.inputValidator =
124+
baseClient?.inputValidator ??
125+
new InputValidator(this as any, { enabled: this.$options.validateInput !== false });
124126

125127
return createClientProxy(this);
126128
}
@@ -348,7 +350,9 @@ export class ClientImpl {
348350
const newClient = new ClientImpl(this.schema, newOptions, this);
349351
// create a new validator to have a fresh schema cache, because plugins may extend the
350352
// query args schemas
351-
newClient.inputValidator = new InputValidator(newClient as any);
353+
newClient.inputValidator = new InputValidator(newClient as any, {
354+
enabled: newOptions.validateInput !== false,
355+
});
352356
return newClient;
353357
}
354358

@@ -367,7 +371,9 @@ export class ClientImpl {
367371
const newClient = new ClientImpl(this.schema, newOptions, this);
368372
// create a new validator to have a fresh schema cache, because plugins may
369373
// extend the query args schemas
370-
newClient.inputValidator = new InputValidator(newClient as any);
374+
newClient.inputValidator = new InputValidator(newClient as any, {
375+
enabled: newClient.$options.validateInput !== false,
376+
});
371377
return newClient;
372378
}
373379

@@ -380,7 +386,9 @@ export class ClientImpl {
380386
const newClient = new ClientImpl(this.schema, newOptions, this);
381387
// create a new validator to have a fresh schema cache, because plugins may
382388
// extend the query args schemas
383-
newClient.inputValidator = new InputValidator(newClient as any);
389+
newClient.inputValidator = new InputValidator(newClient as any, {
390+
enabled: newOptions.validateInput !== false,
391+
});
384392
return newClient;
385393
}
386394

@@ -400,7 +408,9 @@ export class ClientImpl {
400408
$setOptions<Options extends ClientOptions<SchemaDef>>(options: Options): ClientContract<SchemaDef, Options> {
401409
const newClient = new ClientImpl(this.schema, options as ClientOptions<SchemaDef>, this);
402410
// create a new validator to have a fresh schema cache, because options may change validation settings
403-
newClient.inputValidator = new InputValidator(newClient as any);
411+
newClient.inputValidator = new InputValidator(newClient as any, {
412+
enabled: newClient.$options.validateInput !== false,
413+
});
404414
return newClient as unknown as ClientContract<SchemaDef, Options>;
405415
}
406416

packages/orm/src/client/contract.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ export type ClientContract<
138138
): ClientContract<Schema, NewOptions, ExtQueryArgs, ExtClientMembers>;
139139

140140
/**
141-
* Returns a new client enabling/disabling input validations expressed with attributes like
142-
* `@email`, `@regex`, `@@validate`, etc.
141+
* Returns a new client enabling/disabling query args validation.
143142
*
144143
* @deprecated Use {@link $setOptions} instead.
145144
*/

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,23 @@ import { ZodSchemaFactory } from '../../zod/factory';
2525

2626
type GetSchemaFunc<Schema extends SchemaDef> = (model: GetModels<Schema>) => ZodType;
2727

28+
export type InputValidatorOptions = {
29+
/**
30+
* Whether validation is enabled. Defaults to `true`.
31+
*/
32+
enabled?: boolean;
33+
};
34+
2835
export class InputValidator<Schema extends SchemaDef> {
2936
readonly zodFactory: ZodSchemaFactory<Schema>;
37+
private readonly enabled: boolean;
3038

31-
constructor(private readonly client: ClientContract<Schema>) {
39+
constructor(
40+
private readonly client: ClientContract<Schema>,
41+
options?: InputValidatorOptions,
42+
) {
3243
this.zodFactory = new ZodSchemaFactory(client);
44+
this.enabled = options?.enabled !== false;
3345
}
3446

3547
// #region Entry points
@@ -183,6 +195,9 @@ export class InputValidator<Schema extends SchemaDef> {
183195

184196
// TODO: turn it into a Zod schema and cache
185197
validateProcedureInput(proc: string, input: unknown): unknown {
198+
if (!this.enabled) {
199+
return input;
200+
}
186201
const procDef = (this.client.$schema.procedures ?? {})[proc] as ProcedureDef | undefined;
187202
invariant(procDef, `Procedure "${proc}" not found in schema`);
188203

@@ -270,6 +285,9 @@ export class InputValidator<Schema extends SchemaDef> {
270285
// #region Validation helpers
271286

272287
private validate<T>(model: GetModels<Schema>, operation: string, getSchema: GetSchemaFunc<Schema>, args: unknown) {
288+
if (!this.enabled) {
289+
return args as T;
290+
}
273291
const schema = getSchema(model);
274292
const { error, data } = schema.safeParse(args);
275293
if (error) {

packages/orm/src/client/options.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,11 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
193193
fixPostgresTimezone?: boolean;
194194

195195
/**
196-
* Whether to enable input validations expressed with attributes like `@email`, `@regex`,
197-
* `@@validate`, etc. Defaults to `true`.
196+
* Whether to enable query args validation. Defaults to `true`.
197+
*
198+
* **USE WITH CAUTION**, as setting it to `false` will allow malformed input to pass through, causing
199+
* incorrect SQL generation or runtime errors. If you use validation attributes like `@email`, `@regex`,
200+
* etc., in ZModel, they will be ignored too.
198201
*/
199202
validateInput?: boolean;
200203

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export class ZodSchemaFactory<
108108
private readonly allFilterKinds = [...new Set(Object.values(FILTER_PROPERTY_TO_KIND))];
109109
private readonly schema: Schema;
110110
private readonly options: Options;
111+
private readonly extraValidationsEnabled = true;
111112

112113
constructor(client: ClientContract<Schema, Options, ExtQueryArgs, any>);
113114
constructor(schema: Schema, options?: Options);
@@ -125,10 +126,6 @@ export class ZodSchemaFactory<
125126
return this.options.plugins ?? [];
126127
}
127128

128-
private get extraValidationsEnabled() {
129-
return this.options.validateInput !== false;
130-
}
131-
132129
private shouldIncludeRelations(options?: CreateSchemaOptions): boolean {
133130
return options?.relationDepth === undefined || options.relationDepth > 0;
134131
}

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('Custom validation tests', () => {
116116
}
117117
});
118118

119-
it('allows disabling validation', async () => {
119+
it('disabling validation makes validation attributes ineffective', async () => {
120120
const db = await createTestClient(
121121
`
122122
model User {
@@ -180,6 +180,95 @@ describe('Custom validation tests', () => {
180180
).toBeRejectedByValidation();
181181
});
182182

183+
it('disabling validation skips structural validation for all CRUD operations', async () => {
184+
const db = await createTestClient(
185+
`
186+
model User {
187+
id Int @id @default(autoincrement())
188+
email String
189+
name String
190+
}
191+
`,
192+
);
193+
194+
const dbNoValidation = db.$setOptions({ ...db.$options, validateInput: false });
195+
196+
// Helper: assert that a promise rejects but NOT with a Zod-based validation error
197+
// (the cause of a Zod validation error is a ZodError)
198+
const expectNonValidationError = async (promise: Promise<unknown>) => {
199+
try {
200+
await promise;
201+
} catch (err: any) {
202+
if (err.reason === 'invalid-input') {
203+
expect(err.cause?.constructor?.name).not.toBe('ZodError');
204+
}
205+
return;
206+
}
207+
// resolving is also acceptable — it means validation was skipped and the ORM handled it
208+
};
209+
210+
// create - missing required "data" is normally rejected by Zod validation
211+
await expect(db.user.create({} as any)).toBeRejectedByValidation();
212+
// with validation disabled, it skips Zod validation
213+
await expectNonValidationError(dbNoValidation.user.create({} as any));
214+
215+
// update - missing required "where" is normally rejected by Zod validation
216+
await expect(db.user.update({ data: { email: 'new@b.com' } } as any)).toBeRejectedByValidation();
217+
await expectNonValidationError(dbNoValidation.user.update({ data: { email: 'new@b.com' } } as any));
218+
219+
// delete - missing required "where" is normally rejected by Zod validation
220+
await expect(db.user.delete({} as any)).toBeRejectedByValidation();
221+
await expectNonValidationError(dbNoValidation.user.delete({} as any));
222+
223+
// upsert - missing required fields is normally rejected by Zod validation
224+
await expect(db.user.upsert({} as any)).toBeRejectedByValidation();
225+
await expectNonValidationError(dbNoValidation.user.upsert({} as any));
226+
});
227+
228+
it('$setInputValidation toggles validation', async () => {
229+
const db = await createTestClient(
230+
`
231+
model Item {
232+
id Int @id @default(autoincrement())
233+
url String @url
234+
}
235+
`,
236+
);
237+
238+
// validation enabled by default
239+
await expect(db.item.create({ data: { url: 'not-a-url' } })).toBeRejectedByValidation();
240+
241+
// disable via $setInputValidation
242+
const dbDisabled = db.$setInputValidation(false);
243+
await expect(dbDisabled.item.create({ data: { url: 'not-a-url' } })).toResolveTruthy();
244+
245+
// re-enable via $setInputValidation
246+
const dbReEnabled = dbDisabled.$setInputValidation(true);
247+
await expect(dbReEnabled.item.create({ data: { url: 'still-not-a-url' } })).toBeRejectedByValidation();
248+
249+
// valid data should work with re-enabled validation
250+
await expect(dbReEnabled.item.create({ data: { url: 'https://example.com' } })).toResolveTruthy();
251+
});
252+
253+
it('disabling validation at client creation time', async () => {
254+
const db = await createTestClient(
255+
`
256+
model Post {
257+
id Int @id @default(autoincrement())
258+
title String @length(min: 5)
259+
}
260+
`,
261+
{ validateInput: false },
262+
);
263+
264+
// should skip validation since validateInput is false from the start
265+
await expect(db.post.create({ data: { id: 1, title: 'ab' } })).toResolveTruthy();
266+
267+
// re-enable validation
268+
const dbValidated = db.$setInputValidation(true);
269+
await expect(dbValidated.post.create({ data: { title: 'ab' } })).toBeRejectedByValidation();
270+
});
271+
183272
it('checks arg type for validation functions', async () => {
184273
// length() on relation field
185274
await loadSchemaWithError(

0 commit comments

Comments
 (0)