Валидация с обращением к базе данных

Валидация, завязанная на состояние базы данных, отличается от статической проверки тем, что требует асинхронного выполнения. Это означает, что правила проверки не ограничиваются синтаксисом или форматом данных, а опираются на внешние источники: наличие записей, уникальность значений, целостность связей между сущностями.

В рамках class-validator асинхронная валидация реализуется через пользовательские ограничения (custom constraints), поддерживающие работу с промисами и внешними сервисами, включая базы данных через ORM или прямые репозитории.


Асинхронные ограничения и механизм выполнения

Для выполнения проверок, зависящих от базы данных, используется декоратор @ValidatorConstraint с включённым режимом асинхронности.

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
} fr om 'class-validator';

@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  async validate(email: string, args: ValidationArguments): Promise<boolean> {
    return true;
  }

  defaultMessage(args: ValidationArguments): string {
    return 'Email уже существует';
  }
}

Ключевой момент заключается в том, что метод validate может возвращать Promise<boolean>, что позволяет выполнять запросы к базе данных без блокировки потока выполнения.


Интеграция с репозиторием базы данных

На практике ограничение связывается с сервисом или репозиторием, который выполняет запрос к таблице пользователей.

import { DataSource } from 'typeorm';

export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  constructor(private dataSource: DataSource) {}

  async validate(email: string): Promise<boolean> {
    const user = await this.dataSource
      .getRepository('User')
      .findOne({ wh ere: { email } });

    return !user;
  }

  defaultMessage(): string {
    return 'Пользователь с таким email уже существует';
  }
}

В данном случае проверка опирается на результат SQL-запроса. Если запись найдена, валидация считается неуспешной.


Регистрация пользовательского декоратора

Для удобства используется обёртка-декоратор, инкапсулирующая логику constraint.

import { registerDecorator, ValidationOptions } fr om 'class-validator';

export function IsEmailUnique(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName,
      options: validationOptions,
      validator: IsEmailUniqueConstraint,
    });
  };
}

Использование в DTO:

export class CreateUserDto {
  @IsEmailUnique({ message: 'Email занят' })
  email: string;
}

Проверка существования связанной сущности

Часто требуется убедиться, что внешний идентификатор действительно существует в базе данных. Это типичный сценарий для foreign key на уровне бизнес-логики.

@ValidatorConstraint({ async: true })
export class ExistsInDatabaseConstraint implements ValidatorConstraintInterface {
  constructor(private dataSource: DataSource) {}

  async validate(id: number): Promise<boolean> {
    const record = await this.dataSource
      .getRepository('Category')
      .findOne({ wh ere: { id } });

    return Boolean(record);
  }

  defaultMessage(): string {
    return 'Сущность не существует';
  }
}

Такой подход используется при проверке categoryId, roleId, departmentId и аналогичных связей.


Композиция проверок и зависимость от контекста

Валидация может зависеть не только от одного поля, но и от состояния всего объекта DTO. В ValidationArguments доступен объект object, позволяющий учитывать дополнительные параметры.

async validate(value: number, args: ValidationArguments): Promise<boolean> {
  const dto = args.object as any;

  if (dto.isActive === false) {
    return true;
  }

  const exists = await this.repo.findOneBy({ id: value });
  return !!exists;
}

Это позволяет реализовывать условную валидацию, зависящую от других полей.


Использование Prisma в асинхронных constraints

При использовании Prisma доступ к базе осуществляется через PrismaClient.

import { PrismaClient } fr om '@prisma/client';

const prisma = new PrismaClient();

@ValidatorConstraint({ async: true })
export class IsUsernameUniqueConstraint implements ValidatorConstraintInterface {
  async validate(username: string): Promise<boolean> {
    const user = await prisma.user.findUnique({
      wh ere: { username },
    });

    return user === null;
  }

  defaultMessage(): string {
    return 'Username уже используется';
  }
}

Prisma обеспечивает более строгую типизацию, что снижает вероятность ошибок в запросах внутри валидаторов.


Обработка зависимостей и внедрение сервисов

В реальных приложениях constraint не должен напрямую создавать подключения к базе данных. Используется внедрение зависимостей через контейнер.

export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  constructor(private userService: UserService) {}

  async validate(email: string): Promise<boolean> {
    return !(await this.userService.findByEmail(email));
  }
}

Сервис инкапсулирует доступ к базе, что позволяет переиспользовать бизнес-логику и снижает связность.


Производительность асинхронной валидации

При большом количестве проверок, обращающихся к базе данных, возникает риск деградации производительности.

Основные источники нагрузки:

  • множественные независимые запросы на один DTO
  • отсутствие кэширования результатов проверок
  • N+1 запросы при проверке связей

Для минимизации нагрузки применяется агрегация запросов.

const ids = values.map(v => v.id);

const existing = await repo.findBy({ id: In(ids) });

const existingSet = new Set(existing.map(e => e.id));

Такой подход заменяет множество запросов одним.


Кэширование результатов проверок

При повторяющихся валидациях одного и того же значения используется кэширование.

const cache = new Map<string, boolean>();

async validate(email: string): Promise<boolean> {
  if (cache.has(email)) {
    return cache.get(email)!;
  }

  const user = await repo.findOneBy({ email });
  const result = !user;

  cache.set(email, result);

  return result;
}

Кэш особенно эффективен при массовой валидации списков или повторных запросах одного пользователя.


Ограничение количества обращений к базе

Валидация на уровне DTO не должна превращаться в полноценный слой бизнес-логики. При избыточном количестве проверок ухудшается читаемость и увеличивается время обработки запроса.

Часто применяется стратегия переноса части проверок в сервисный слой, оставляя в class-validator только критические ограничения:

  • уникальность ключевых полей
  • существование внешних ключей
  • базовые бизнес-ограничения

Асинхронные цепочки валидации

Несколько constraint-ов могут выполняться последовательно или параллельно в зависимости от конфигурации. Каждый из них может выполнять независимый запрос к базе.

@Validate(IsEmailUnique)
@Validate(IsEmailAllowedDomain)
email: string;

Каждый constraint формирует отдельный этап проверки, что требует контроля общего количества запросов и их стоимости.


Ошибки и обработка исключений при работе с БД

При обращении к базе данных внутри валидаторов возможны исключения: потеря соединения, таймауты, ошибки запросов.

Рекомендуется изолировать такие ситуации и интерпретировать их как неуспешную валидацию.

async validate(id: number): Promise<boolean> {
  try {
    const entity = await repo.findOneBy({ id });
    return !!entity;
  } catch (e) {
    return false;
  }
}

Это предотвращает проброс ошибок уровня инфраструктуры в слой валидации DTO.


Особенности работы в контексте HTTP-запросов

Асинхронные проверки с доступом к базе данных выполняются в рамках одного жизненного цикла запроса. Это означает, что каждая валидация увеличивает общее время ответа.

При проектировании схемы DTO учитывается баланс между:

  • глубиной проверки
  • количеством запросов к базе
  • допустимой задержкой ответа

Оптимизация достигается за счёт минимизации количества уникальных запросов и переиспользования результатов внутри одного запроса.