Опирается на правила:
R-TYPEORM-QRY-1…R-TYPEORM-QRY-5иR-TYPEORM-QRY-X1…R-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() частичного объекта без предварительного load | R-TYPEORM-QRY-X2 | Load полного агрегата → мутация → save(toEntity(...)) |
find() без take на большой таблице | R-TYPEORM-QRY-X3 | take(size) обязательно; keyset для лент |
Сырой SQL с интерполяцией (\WHERE status = '${x}'`) |R-TYPEORM-QRY-X4` | Именованные или позиционные bind-параметры | |
(await find()).length вместо отдельного count | R-TYPEORM-QRY-3 | getManyAndCount или отдельный COUNT(*) |
| Read-проекция из полного агрегата в query-side | R-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-параметры.