Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як NestJS інтегрується з TypeORM?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Інтеграція NestJS + TypeORM** відбувається в три кроки: одноразово налаштовуєш з'єднання через `TypeOrmModule.forRoot()`, реєструєш сутності в модулі через `TypeOrmModule.forFeature([Entity])`, потім впроваджуєш `Repository<Entity>` через `@InjectRepository()`. ```typescript // feature-модуль TypeOrmModule.forFeature([User]) // конструктор сервісу @InjectRepository(User) private usersRepo: Repository<User>; ``` **Головне:** `synchronize: true` автоматично змінює схему БД при старті - нормально в розробці, небезпечно в продакшені.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Інтеграція NestJS + TypeORM** - з'єднує шар бази даних з модульною системою NestJS через пакет `@nestjs/typeorm`, надаючи кожному feature-модулю власний типізований репозиторій. ## Теорія ### TL;DR - Встановлюєш `@nestjs/typeorm typeorm pg`, один раз налаштовуєш `TypeOrmModule.forRoot()` - Кожен feature-модуль реєструє свої сутності через `TypeOrmModule.forFeature([Entity])` - У сервіс впроваджуєш `Repository<Entity>` через декоратор `@InjectRepository()` - `synchronize: true` автоматично змінює схему БД - безпечно в розробці, небезпечно в продакшені - Для транзакцій впроваджуєш `DataSource`, а не репозиторій ### Налаштування модуля ```typescript // app.module.ts TypeOrmModule.forRoot({ type: 'postgres', host: process.env.DB_HOST, port: +process.env.DB_PORT, username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, autoLoadEntities: true, // підтягує всі сутності з forFeature() автоматично synchronize: process.env.NODE_ENV !== 'production', // ⚠️ тільки для розробки }) ``` `autoLoadEntities: true` означає, що не потрібно перераховувати сутності в `forRoot()`. Кожен виклик `TypeOrmModule.forFeature([User])` реєструє їх без змін у кореневому конфігу. ### Визначення сутності Сутності - це звичайні TypeScript-класи з декораторами TypeORM. `@Entity('users')` прив'язує клас до таблиці. `@Column`, `@PrimaryGeneratedColumn` і декоратори зв'язків описують схему. ```typescript // users/user.entity.ts @Entity('users') export class User { @PrimaryGeneratedColumn() id: number; @Column({ length: 100 }) name: string; @Column({ unique: true }) email: string; @Column({ select: false }) // не потрапляє в SELECT без явного запиту password: string; @Column({ default: 'user' }) role: string; @OneToMany(() => Post, post => post.user) posts: Post[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } ``` Параметр `select: false` на полі `password` - один з тих деталей, які регулярно питають на співбесідах. Стовпець є в базі, але TypeORM не включає його в результати, поки не викличеш `.addSelect('user.password')` явно в query builder. ### Шаблон репозиторію (Repository pattern) Стандартний спосіб роботи з БД у NestJS. Реєструєш сутність у feature-модулі, потім впроваджуєш типізований репозиторій у сервіс. ```typescript // users/users.module.ts @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], controllers: [UsersController], }) export class UsersModule {} // users/users.service.ts @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly usersRepo: Repository<User>, ) {} findAll(): Promise<User[]> { return this.usersRepo.find(); } findOne(id: number): Promise<User | null> { return this.usersRepo.findOneBy({ id }); } async create(dto: CreateUserDto): Promise<User> { const user = this.usersRepo.create(dto); // створює інстанс, НЕ зберігає return this.usersRepo.save(user); // INSERT або UPDATE } async remove(id: number): Promise<void> { await this.usersRepo.delete(id); } } ``` `create()` просто будує об'єкт класу з DTO. До бази нічого не йде, поки не викличеш `save()`. Це часта точка непорозуміння. ### Зв'язки і query builder Для простого завантаження зв'язків передай `relations` у `find()`: ```typescript const users = await this.usersRepo.find({ relations: { posts: true }, }); ``` Коли потрібна фільтрація, сортування або JOIN-и, використовуй `createQueryBuilder`: ```typescript const activeUsers = await this.usersRepo .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.isActive = :active', { active: true }) .orderBy('user.createdAt', 'DESC') .take(10) .getMany(); ``` Query builder вирішує проблему N+1, яка виникає при завантаженні зв'язків у циклі. Один запит отримує все за один раунд. ### Транзакції Для операцій, які мають виконатись разом або не виконатись взагалі, впроваджуй `DataSource` і використовуй `dataSource.transaction()`: ```typescript @Injectable() export class OrdersService { constructor(private readonly dataSource: DataSource) {} async createOrder(dto: CreateOrderDto) { return this.dataSource.transaction(async manager => { const order = manager.create(Order, dto); await manager.save(order); await manager.decrement( Product, { id: dto.productId }, 'stock', dto.quantity, ); return order; }); } } ``` `manager` всередині колбеку - це `EntityManager`, прив'язаний до транзакції. Якщо будь-яка операція кидає помилку, вся транзакція відкочується автоматично. ### Типові помилки **1. `synchronize: true` в продакшені.** TypeORM змінюватиме або видалятиме стовпці, щоб привести їх у відповідність до сутностей. Один деплой може знищити стовпець з реальними даними. ```typescript // ❌ ніколи не роби так в продакшені synchronize: true, // ✅ використовуй міграції synchronize: process.env.NODE_ENV !== 'production', ``` **2. Пропущений `TypeOrmModule.forFeature()` у feature-модулі.** ```typescript // ❌ UsersService отримає помилку "No metadata found for User" @Module({ providers: [UsersService], }) export class UsersModule {} // ✅ реєструй перед впровадженням @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], }) export class UsersModule {} ``` **3. Очікування, що `create()` збереже дані.** `Repository.create()` лише створює інстанс класу. Для запису в базу потрібен `save()`. **4. Звернення до зв'язків без їх завантаження.** TypeORM 0.3+ прибрав неявне ліниве (lazy) завантаження. Якщо звернутись до `user.posts` без `{ relations: { posts: true } }` або query builder, отримаєш порожній масив або `undefined`, а не помилку бази. **5. Транзакції через звичайний репозиторій.** Впроваджений `Repository<Entity>` не прив'язується до контексту транзакції. Завжди використовуй `manager` з колбеку `dataSource.transaction()`. ### Де зустрічається в продакшені - Сервіси автентифікації: `Repository<User>`, пошук за email, поле `password` через `.addSelect()` - Пагіновані списки: query builder з `.skip()` і `.take()` - М'яке видалення (soft delete): `@DeleteDateColumn()` і `find({ withDeleted: false })` - Мультитенантні застосунки: `TypeOrmModule.forRootAsync()` з фабрикою, яка читає конфіг тенанта - Кілька баз даних: два виклики `forRoot()` з різними `name`, потім `@InjectRepository(Entity, 'secondaryDb')` ### Питання від інтерв'юера **Q:** Яка різниця між `save()` і `insert()` в TypeORM? **A:** `save()` перевіряє, чи є в сутності первинний ключ. Є - запускає UPDATE, немає - INSERT. `insert()` завжди робить чистий INSERT і пропускає lifecycle hooks та каскадну логіку. **Q:** Чому для транзакцій використовують `DataSource`, а не репозиторій? **A:** Репозиторій не знає про контекст транзакції. `EntityManager` з `dataSource.transaction()` ділить одне з'єднання з базою для всього колбеку, тому всі операції потрапляють в одну транзакцію. **Q:** Що робить `autoLoadEntities: true`? **A:** Наказує TypeORM реєструвати будь-яку сутність, передану в `TypeOrmModule.forFeature()` в будь-якому модулі. Без цього кожну сутність треба вручну перераховувати в `forRoot({ entities: [...] })`. **Q:** Як уникнути проблеми N+1 при роботі зі зв'язками? **A:** Використовуй `createQueryBuilder` з `leftJoinAndSelect` замість завантаження зв'язків окремо. Для невеликих наборів даних з передбачуваною кількістю записів `find({ relations: ... })` теж підходить. **Q:** Чи можна підключити кілька баз даних в одному NestJS-застосунку? **A:** Так. Викликай `TypeOrmModule.forRoot()` кілька разів з різними параметрами `name`. Потім передавай ім'я з'єднання другим аргументом в `@InjectRepository(Entity, 'connectionName')`. ## Приклади ### Базовий CRUD-сервіс з перевіркою дублікатів ```typescript // users/users.service.ts @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly usersRepo: Repository<User>, ) {} async findByEmail(email: string): Promise<User | null> { return this.usersRepo.findOneBy({ email }); } async createUser(dto: CreateUserDto): Promise<User> { const existing = await this.findByEmail(dto.email); if (existing) throw new ConflictException('Email already taken'); const user = this.usersRepo.create(dto); return this.usersRepo.save(user); } } ``` `findByEmail` використовує скорочену форму `findOneBy`. Перевірка дублікатів відбувається до збереження, тому сервіс повертає зрозумілий `409`, а не сиру помилку constraint бази. ### Пагінований список із загальним підрахунком ```typescript // articles/articles.service.ts async findPaginated(page: number, limit: number) { const [items, total] = await this.articlesRepo .createQueryBuilder('article') .leftJoinAndSelect('article.author', 'author') .where('article.published = :published', { published: true }) .orderBy('article.createdAt', 'DESC') .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { items, total, page, totalPages: Math.ceil(total / limit) }; } ``` `getManyAndCount()` повертає кортеж: масив результатів і загальну кількість рядків без пагінації. Один запит до бази замість двох. ### Створення замовлення з транзакцією ```typescript // orders/orders.service.ts async placeOrder(userId: number, dto: CreateOrderDto): Promise<Order> { return this.dataSource.transaction(async manager => { const product = await manager.findOneByOrFail(Product, { id: dto.productId }); if (product.stock < dto.quantity) { throw new BadRequestException('Not enough stock'); } const order = manager.create(Order, { userId, ...dto }); await manager.save(order); await manager.decrement(Product, { id: dto.productId }, 'stock', dto.quantity); return order; }); } ``` `findOneByOrFail` кидає `EntityNotFoundError`, якщо продукт не знайдено. За замовчуванням NestJS перетворить це на 500. Щоб повернути правильний 404, додай глобальний фільтр винятків або обгорни виклик у try/catch.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.