Опирается на правила: R-TYPEORM-QRY-1R-TYPEORM-QRY-5 и R-TYPEORM-QRY-X1R-TYPEORM-QRY-X4 из TypeORM Style Guide → раздел 5. Запросы. Здесь — те же правила подробно, с примерами на доменах Order, Product, Customer.

Важно знать

  • find* с явным relations: [...] — против N+1; TypeORM не загружает связанные Entity, пока не указать явно.
  • Lazy relations (Promise<Item[]>) запрещены: скрытые запросы вылетают из маппера и сериализатора — это N+1 без какого-либо контроля.
  • Update существующего агрегата — всегда load → мутация домена → save(toEntity(...)) полного агрегата; частичный save() без предварительного load молча затирает поля.
  • Пагинация — take/skip или keyset по (createdAt, id); count отдельным запросом (getManyAndCount ок для offset); никогда (await find()).length.
  • Read-проекции (CQRS query-side) — отдельный TypeOrm<X>ViewRepository: getRawMany или dataSource.query с bind-параметрами, результат сразу в read-DTO.
  • Bind-параметры — именованные: .where('o.status = :status', { status }); интерполяция строк запрещена — SQL-injection.
  • find() без take на большой таблице — OOM или таймаут; take обязателен на UI-эндпоинтах.

Раздел 5 правил описывает, как репозиторий строит запросы: явная загрузка связей, правильный update, пагинация, read-проекции и безопасные параметры. Ни одна из этих тем не специфична только для TypeORM — те же архитектурные принципы работают в jOOQ (Java) и SQLAlchemy (Python). Отличается механика: здесь Repository<T> и QueryBuilder, а не DSLContext и MULTISET.

Явные relations — против N+1

R-TYPEORM-QRY-1: запросы через Repository<T>.find* с явным relations: [...] или QueryBuilder с явным leftJoinAndSelect.

TypeORM по умолчанию не загружает связанные Entity. Если не указать relations, поле items в OrderEntity окажется undefined после findOne. Разработчик попытается обратиться к нему в маппере — и либо получит ошибку, либо, хуже, TypeORM тихо сделает отдельный запрос (если поле помечено lazy).

// ХОРОШО — явные relations
async findById(id: string): Promise<Order | null> {
  const entity = await this.repo.findOne({
    where: { id },
    relations: ['items', 'items.product'],
  });
  return entity ? this.mapper.toDomain(entity) : null;
}
// ХОРОШО — QueryBuilder с leftJoinAndSelect
async findByCustomer(customerId: string): Promise<Order[]> {
  const entities = await this.repo
    .createQueryBuilder('o')
    .leftJoinAndSelect('o.items', 'i')
    .leftJoinAndSelect('i.product', 'p')
    .where('o.customerId = :customerId', { customerId })
    .getMany();
  return entities.map(this.mapper.toDomain.bind(this.mapper));
}
// ПЛОХО — lazy relation, скрытый N+1
@Entity()
export class OrderEntity {
  @OneToMany(() => OrderItemEntity, (i) => i.order, { lazy: true })
  items: Promise<OrderItemEntity[]>;   // R-TYPEORM-QRY-X1
}

Lazy relation (Promise<OrderItemEntity[]>) активирует скрытый запрос при первом await entity.items — из маппера, из сериализатора NestJS, откуда угодно. Если маппер вызывается для списка из 50 заказов, получаем 50 дополнительных запросов. Явный relations или leftJoinAndSelect загружает всё одним JOIN.

Update агрегата — load полностью, затем save

R-TYPEORM-QRY-2: обновление существующего агрегата — load → мутация домена → save(toEntity(...)) полного агрегата в транзакции; точечный апдейт отдельных полей — через update().set().where() явно, без save() частичного объекта.

TypeORM молча делает partial update, когда в save() передать объект без некоторых полей. Это значит: поля, которых нет в объекте, останутся в БД как есть или обнулятся — поведение зависит от версии TypeORM и настроек Entity. Предсказать это без знания внутренностей невозможно.

// ХОРОШО — load полного агрегата, мутация, save полного Entity
async handle(cmd: CancelOrderCommand): Promise<void> {
  const order = await this.orderRepository.findById(cmd.orderId);
  if (!order) throw new OrderNotFoundException(cmd.orderId);

  order.cancel();

  await this.orderRepository.save(order);
}

// в репозитории:
async save(order: Order): Promise<void> {
  await this.repo.save(this.mapper.toEntity(order));
}
// ХОРОШО — точечный UPDATE через QueryBuilder (без предварительного load)
async markAsShipped(orderId: string): Promise<void> {
  await this.repo
    .createQueryBuilder()
    .update(OrderEntity)
    .set({ status: OrderStatus.SHIPPED, shippedAt: new Date() })
    .where('id = :orderId', { orderId })
    .execute();
}
// ПЛОХО — save() частичного объекта без предварительного load
async updateStatus(orderId: string, status: OrderStatus): Promise<void> {
  await this.repo.save({ id: orderId, status });  // R-TYPEORM-QRY-X2 — поля без значений затрутся
}

Правило простое: если нужно обновить агрегат через доменную логику — load + save полного графа. Если нужен точечный UPDATE конкретных полей без загрузки агрегата — createQueryBuilder().update().set().where() явно.

Пагинация — take/skip и keyset

R-TYPEORM-QRY-3: offset-пагинация через take/skip; keyset по (createdAt, id) для лент; count — отдельным запросом или getManyAndCount; никакого (await find()).length.

Offset-пагинация (take/skip)

async findAll(
  filter: OrderFilter,
  page: number,
  size: number,
): Promise<PaginationView<Order>> {
  const qb = this.repo
    .createQueryBuilder('o')
    .leftJoinAndSelect('o.items', 'i')
    .where(this.buildWhere(filter, 'o'))
    .orderBy('o.createdAt', 'DESC')
    .addOrderBy('o.id', 'DESC')
    .take(size)
    .skip(page * size);

  const [entities, total] = await qb.getManyAndCount();
  return PaginationView.of(
    entities.map(this.mapper.toDomain.bind(this.mapper)),
    page,
    size,
    total,
  );
}

getManyAndCount выполняет два запроса под капотом — SELECT с LIMIT/OFFSET и отдельный COUNT. Это правильно: не загружать все строки в память и звать .length.

Keyset-пагинация по (createdAt, id)

Для лент событий, уведомлений, истории — offset неэффективен на глубоких страницах. Keyset по составному ключу (createdAt, id) стабилен при вставках между страницами:

async findCustomerOrders(
  customerId: string,
  cursor: OrderCursor | null,
  size: number,
): Promise<Order[]> {
  const qb = this.repo
    .createQueryBuilder('o')
    .leftJoinAndSelect('o.items', 'i')
    .where('o.customerId = :customerId', { customerId })
    .orderBy('o.createdAt', 'DESC')
    .addOrderBy('o.id', 'DESC')
    .take(size + 1);  // +1 для определения hasNext

  if (cursor) {
    qb.andWhere(
      '(o.createdAt < :createdAt OR (o.createdAt = :createdAt AND o.id < :id))',
      { createdAt: cursor.createdAt, id: cursor.id },
    );
  }

  const entities = await qb.getMany();
  return entities.slice(0, size).map(this.mapper.toDomain.bind(this.mapper));
}

total при keyset не вычисляется — это осознанный компромисс: для ленты пользователю не нужно знать «страница 3 из 47».

Read-проекции — отдельный ViewRepository

R-TYPEORM-QRY-4: CQRS query-side — отдельный TypeOrm<X>ViewRepository: raw select через getRawMany или dataSource.query с bind-параметрами в read-DTO, не полный агрегат.

Загружать полный агрегат Order со всеми связями ради отображения списка строк в таблице — это избыточно. Query-side читает ровно то, что нужно для DTO:

// core/order/port/order-view.repository.ts
export interface OrderViewRepository {
  findSummaries(filter: OrderFilter, page: number, size: number): Promise<PaginationView<OrderSummary>>;
}

export const ORDER_VIEW_REPOSITORY = Symbol('OrderViewRepository');
// adapters/out/persistence/order/typeorm-order-view.repository.ts
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
  constructor(private readonly dataSource: DataSource) {}

  async findSummaries(
    filter: OrderFilter,
    page: number,
    size: number,
  ): Promise<PaginationView<OrderSummary>> {
    const params: unknown[] = [];
    let paramIdx = 1;

    let where = 'WHERE 1=1';
    if (filter.customerId) {
      where += ` AND o.customer_id = $${paramIdx++}`;
      params.push(filter.customerId);
    }
    if (filter.status) {
      where += ` AND o.status = $${paramIdx++}`;
      params.push(filter.status);
    }

    const rows = await this.dataSource.query<OrderSummaryRow[]>(
      `SELECT o.id, o.status, o.total, o.created_at, c.name AS customer_name
       FROM orders o
       JOIN customers c ON c.id = o.customer_id
       ${where}
       ORDER BY o.created_at DESC
       LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
      [...params, size, page * size],
    );

    const [{ count }] = await this.dataSource.query<[{ count: string }]>(
      `SELECT COUNT(*) FROM orders o ${where}`,
      params,
    );

    return PaginationView.of(rows.map(toOrderSummary), page, size, Number(count));
  }
}

Или через getRawMany QueryBuilder с именованными параметрами:

async findProductStats(filter: ProductFilter): Promise<ProductStatRow[]> {
  return this.dataSource
    .createQueryBuilder()
    .select('p.id', 'productId')
    .addSelect('p.name', 'name')
    .addSelect('SUM(oi.quantity)', 'totalSold')
    .from(ProductEntity, 'p')
    .leftJoin('order_items', 'oi', 'oi.product_id = p.id')
    .where('p.category = :category', { category: filter.category })
    .groupBy('p.id')
    .orderBy('totalSold', 'DESC')
    .take(50)
    .getRawMany<ProductStatRow>();
}

getRawMany возвращает plain-объекты без маппинга в Entity — именно это нужно для read-проекции. Никакого полного агрегата, никакого маппера toDomain.

Именованные bind-параметры

R-TYPEORM-QRY-5: bind-параметры — именованные в QueryBuilder (.where('o.status = :status', { status })) или позиционные в dataSource.query ($1, $2); интерполяция в строку запрещена.

// ХОРОШО — именованные параметры в QueryBuilder
async findByStatus(status: OrderStatus): Promise<Order[]> {
  const entities = await this.repo
    .createQueryBuilder('o')
    .where('o.status = :status', { status })
    .andWhere('o.customerId = :customerId', { customerId: 'sber-001' })
    .getMany();
  return entities.map(this.mapper.toDomain.bind(this.mapper));
}
// ХОРОШО — позиционные параметры в сыром SQL
async findActiveCustomers(minOrderCount: number): Promise<CustomerRow[]> {
  return this.dataSource.query<CustomerRow[]>(
    `SELECT c.id, c.name FROM customers c
     WHERE (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) >= $1`,
    [minOrderCount],
  );
}
// ПЛОХО — интерполяция строк, SQL-injection
async findByStatus(status: string): Promise<Order[]> {
  return this.dataSource.query(
    `SELECT * FROM orders WHERE status = '${status}'`,  // R-TYPEORM-QRY-X4
  );
}

Правило без исключений: любое пользовательское значение в запрос идёт только через параметр. Это касается и dataSource.query, и QueryBuilder, и find*.

Что запрещено

АнтипаттернПравилоЧто взамен
Lazy relations (Promise<Item[]> в Entity)R-TYPEORM-QRY-X1Явный relations: [...] или leftJoinAndSelect
save() частичного объекта без предварительного loadR-TYPEORM-QRY-X2Load полного агрегата → мутация → save(toEntity(...))
find() без take на большой таблицеR-TYPEORM-QRY-X3take(size) обязательно; keyset для лент
Сырой SQL с интерполяцией (\WHERE status = '${x}'`) |R-TYPEORM-QRY-X4`Именованные или позиционные bind-параметры
(await find()).length вместо отдельного countR-TYPEORM-QRY-3getManyAndCount или отдельный COUNT(*)
Read-проекция из полного агрегата в query-sideR-TYPEORM-QRY-4Отдельный TypeOrm<X>ViewRepository с getRawMany

Куда дальше

  • node/repository-pattern.md — порт в core/, реализация TypeOrm<X>Repository, явный маппер; что не должно выходить из persistence-адаптера.
  • node/transactions.md — граница транзакции на Handler через typeorm-transactional; как QueryBuilder и find* работают внутри транзакционного EntityManager.
  • Построение запросов в jOOQ (Java) — те же принципы на jOOQ DSL: явные join'ы, fetch-методы под цель, EXISTS, bind-параметры.