Skip to content
Merged
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
20 changes: 15 additions & 5 deletions packages/orm/src/client/client-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ export class ClientImpl {
}

this.kysely = new Kysely(this.kyselyProps);
this.inputValidator = baseClient?.inputValidator ?? new InputValidator(this as any);
this.inputValidator =
baseClient?.inputValidator ??
new InputValidator(this as any, { enabled: this.$options.validateInput !== false });

return createClientProxy(this);
}
Expand Down Expand Up @@ -348,7 +350,9 @@ export class ClientImpl {
const newClient = new ClientImpl(this.schema, newOptions, this);
// create a new validator to have a fresh schema cache, because plugins may extend the
// query args schemas
newClient.inputValidator = new InputValidator(newClient as any);
newClient.inputValidator = new InputValidator(newClient as any, {
enabled: newOptions.validateInput !== false,
});
return newClient;
}

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

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

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

Expand Down
3 changes: 1 addition & 2 deletions packages/orm/src/client/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,7 @@ export type ClientContract<
): ClientContract<Schema, NewOptions, ExtQueryArgs, ExtClientMembers>;

/**
* Returns a new client enabling/disabling input validations expressed with attributes like
* `@email`, `@regex`, `@@validate`, etc.
* Returns a new client enabling/disabling query args validation.
*
* @deprecated Use {@link $setOptions} instead.
*/
Expand Down
20 changes: 19 additions & 1 deletion packages/orm/src/client/crud/validator/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,23 @@ import { ZodSchemaFactory } from '../../zod/factory';

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

export type InputValidatorOptions = {
/**
* Whether validation is enabled. Defaults to `true`.
*/
enabled?: boolean;
};

export class InputValidator<Schema extends SchemaDef> {
readonly zodFactory: ZodSchemaFactory<Schema>;
private readonly enabled: boolean;

constructor(private readonly client: ClientContract<Schema>) {
constructor(
private readonly client: ClientContract<Schema>,
options?: InputValidatorOptions,
) {
this.zodFactory = new ZodSchemaFactory(client);
this.enabled = options?.enabled !== false;
}

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

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

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

private validate<T>(model: GetModels<Schema>, operation: string, getSchema: GetSchemaFunc<Schema>, args: unknown) {
if (!this.enabled) {
return args as T;
}
const schema = getSchema(model);
const { error, data } = schema.safeParse(args);
if (error) {
Expand Down
7 changes: 5 additions & 2 deletions packages/orm/src/client/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,11 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
fixPostgresTimezone?: boolean;

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

Expand Down
5 changes: 1 addition & 4 deletions packages/orm/src/client/zod/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export class ZodSchemaFactory<
private readonly allFilterKinds = [...new Set(Object.values(FILTER_PROPERTY_TO_KIND))];
private readonly schema: Schema;
private readonly options: Options;
private readonly extraValidationsEnabled = true;

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

private get extraValidationsEnabled() {
return this.options.validateInput !== false;
}

private shouldIncludeRelations(options?: CreateSchemaOptions): boolean {
return options?.relationDepth === undefined || options.relationDepth > 0;
}
Expand Down
91 changes: 90 additions & 1 deletion tests/e2e/orm/validation/custom-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('Custom validation tests', () => {
}
});

it('allows disabling validation', async () => {
it('disabling validation makes validation attributes ineffective', async () => {
const db = await createTestClient(
`
model User {
Expand Down Expand Up @@ -180,6 +180,95 @@ describe('Custom validation tests', () => {
).toBeRejectedByValidation();
});

it('disabling validation skips structural validation for all CRUD operations', async () => {
const db = await createTestClient(
`
model User {
id Int @id @default(autoincrement())
email String
name String
}
`,
);

const dbNoValidation = db.$setOptions({ ...db.$options, validateInput: false });

// Helper: assert that a promise rejects but NOT with a Zod-based validation error
// (the cause of a Zod validation error is a ZodError)
const expectNonValidationError = async (promise: Promise<unknown>) => {
try {
await promise;
} catch (err: any) {
if (err.reason === 'invalid-input') {
expect(err.cause?.constructor?.name).not.toBe('ZodError');
}
return;
}
// resolving is also acceptable — it means validation was skipped and the ORM handled it
};

// create - missing required "data" is normally rejected by Zod validation
await expect(db.user.create({} as any)).toBeRejectedByValidation();
// with validation disabled, it skips Zod validation
await expectNonValidationError(dbNoValidation.user.create({} as any));

// update - missing required "where" is normally rejected by Zod validation
await expect(db.user.update({ data: { email: 'new@b.com' } } as any)).toBeRejectedByValidation();
await expectNonValidationError(dbNoValidation.user.update({ data: { email: 'new@b.com' } } as any));

// delete - missing required "where" is normally rejected by Zod validation
await expect(db.user.delete({} as any)).toBeRejectedByValidation();
await expectNonValidationError(dbNoValidation.user.delete({} as any));

// upsert - missing required fields is normally rejected by Zod validation
await expect(db.user.upsert({} as any)).toBeRejectedByValidation();
await expectNonValidationError(dbNoValidation.user.upsert({} as any));
});

it('$setInputValidation toggles validation', async () => {
const db = await createTestClient(
`
model Item {
id Int @id @default(autoincrement())
url String @url
}
`,
);

// validation enabled by default
await expect(db.item.create({ data: { url: 'not-a-url' } })).toBeRejectedByValidation();

// disable via $setInputValidation
const dbDisabled = db.$setInputValidation(false);
await expect(dbDisabled.item.create({ data: { url: 'not-a-url' } })).toResolveTruthy();

// re-enable via $setInputValidation
const dbReEnabled = dbDisabled.$setInputValidation(true);
await expect(dbReEnabled.item.create({ data: { url: 'still-not-a-url' } })).toBeRejectedByValidation();

// valid data should work with re-enabled validation
await expect(dbReEnabled.item.create({ data: { url: 'https://example.com' } })).toResolveTruthy();
});

it('disabling validation at client creation time', async () => {
const db = await createTestClient(
`
model Post {
id Int @id @default(autoincrement())
title String @length(min: 5)
}
`,
{ validateInput: false },
);

// should skip validation since validateInput is false from the start
await expect(db.post.create({ data: { id: 1, title: 'ab' } })).toResolveTruthy();

// re-enable validation
const dbValidated = db.$setInputValidation(true);
await expect(dbValidated.post.create({ data: { title: 'ab' } })).toBeRejectedByValidation();
});

it('checks arg type for validation functions', async () => {
// length() on relation field
await loadSchemaWithError(
Expand Down
Loading