TypeORM avec NestJS
ORM TypeScript-first pour PostgreSQL, MySQL, SQLite…
TypeORM 0.3 s'intègre parfaitement avec NestJS via @nestjs/typeorm. Les entités sont des classes décorées qui mappent les tables SQL. Le Repository pattern simplifie toutes les opérations CRUD.
Installation & configuration
npm install @nestjs/typeorm typeorm pg
# ou pour MySQL : npm install mysql2
# ou pour SQLite : npm install better-sqlite3
// app.module.ts
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: +process.env.DB_PORT || 5432,
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASS || 'postgres',
database: process.env.DB_NAME || 'nestjs_db',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: process.env.NODE_ENV !== 'production', // JAMAIS en prod !
logging: process.env.NODE_ENV === 'development',
})
Entités
import {
Entity, Column, PrimaryGeneratedColumn,
CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
Index, BeforeInsert, BeforeUpdate,
} from 'typeorm';
import * as bcrypt from 'bcrypt';
@Entity('users')
@Index(['email'], { unique: true })
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column({ select: false }) // Jamais retourné par défaut
password: string;
@Column({ type: 'enum', enum: ['user', 'admin'], default: 'user' })
role: 'user' | 'admin';
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@DeleteDateColumn() // Soft delete
deletedAt: Date;
@BeforeInsert()
@BeforeUpdate()
async hashPassword() {
if (this.password) {
this.password = await bcrypt.hash(this.password, 12);
}
}
}
Types de colonnes
@Entity('products')
export class Product {
@PrimaryGeneratedColumn('uuid') // UUID auto-généré
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price: number;
@Column({ type: 'int', default: 0 })
stock: number;
@Column({ type: 'json', nullable: true })
metadata: Record<string, any>;
@Column({ type: 'simple-array', nullable: true })
tags: string[];
@Column({ type: 'boolean', default: true })
isAvailable: boolean;
@Column({ name: 'image_url', nullable: true })
imageUrl: string; // camelCase côté TypeScript, snake_case en DB
}
Relations entre entités
OneToMany / ManyToOne
// user.entity.ts
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@OneToMany(() => Post, (post) => post.author, { cascade: true })
posts: Post[];
@OneToOne(() => Profile, (profile) => profile.user, { cascade: true })
@JoinColumn()
profile: Profile;
}
// post.entity.ts
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author: User;
@Column({ name: 'author_id' })
authorId: number;
}
ManyToMany
@Entity('posts')
export class Post {
@ManyToMany(() => Tag, (tag) => tag.posts, { cascade: true })
@JoinTable({
name: 'post_tags',
joinColumn: { name: 'post_id' },
inverseJoinColumn: { name: 'tag_id' },
})
tags: Tag[];
}
@Entity('tags')
export class Tag {
@ManyToMany(() => Post, (post) => post.tags)
posts: Post[];
}
Repository pattern
// users.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
})
export class UsersModule {}
// users.service.ts
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
// Trouver avec relations
findAll(): Promise<User[]> {
return this.userRepo.find({
relations: { posts: true, profile: true },
where: { isActive: true },
order: { createdAt: 'DESC' },
take: 20,
skip: 0,
});
}
// Trouver un avec select spécifique
findByEmail(email: string): Promise<User | null> {
return this.userRepo.findOne({
where: { email },
select: ['id', 'email', 'password', 'role'],
});
}
// Sauvegarder (insert ou update)
async create(dto: CreateUserDto): Promise<User> {
const user = this.userRepo.create(dto);
return this.userRepo.save(user);
}
// Mise à jour partielle
async update(id: number, dto: UpdateUserDto): Promise<User> {
await this.userRepo.update(id, dto);
return this.findOne(id);
}
// Soft delete
async softDelete(id: number): Promise<void> {
await this.userRepo.softDelete(id);
}
// Restore soft delete
async restore(id: number): Promise<void> {
await this.userRepo.restore(id);
}
}
QueryBuilder
Pour les requêtes complexes que le Repository ne peut pas exprimer facilement.
// Recherche avec JOIN et conditions complexes
async searchUsers(query: string, role: string): Promise<User[]> {
return this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.profile', 'profile')
.leftJoinAndSelect('user.posts', 'post', 'post.published = :pub', { pub: true })
.where('user.isActive = :active', { active: true })
.andWhere(
new Brackets(qb => {
qb.where('user.name ILIKE :q', { q: `%${query}%` })
.orWhere('user.email ILIKE :q', { q: `%${query}%` });
})
)
.andWhere('user.role = :role', { role })
.orderBy('user.createdAt', 'DESC')
.take(10)
.skip(0)
.getMany();
}
// Agrégations
async getUserStats(): Promise<any> {
return this.userRepo
.createQueryBuilder('user')
.select('user.role', 'role')
.addSelect('COUNT(*)', 'count')
.where('user.isActive = true')
.groupBy('user.role')
.getRawMany();
}
// Update avec QueryBuilder
async activateUsers(ids: number[]): Promise<void> {
await this.userRepo
.createQueryBuilder()
.update(User)
.set({ isActive: true })
.whereInIds(ids)
.execute();
}
Migrations
En production, n'utilisez JAMAIS synchronize: true. Utilisez des migrations pour contrôler les changements de schéma.
Configuration DataSource
// data-source.ts
import { DataSource } from 'typeorm';
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
migrationsTableName: 'migrations',
});
AppDataSource.initialize();
# Générer une migration depuis les changements d'entités
npx typeorm migration:generate src/migrations/AddUserRole -d src/data-source.ts
# Créer une migration vide
npx typeorm migration:create src/migrations/SeedUsers
# Exécuter les migrations
npx typeorm migration:run -d src/data-source.ts
# Annuler la dernière migration
npx typeorm migration:revert -d src/data-source.ts
Migration exemple
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddUserAvatar1700000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn('users', new TableColumn({
name: 'avatar_url',
type: 'varchar',
isNullable: true,
}));
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('users', 'avatar_url');
}
}
Transactions
import { DataSource } from 'typeorm';
@Injectable()
export class OrdersService {
constructor(
private dataSource: DataSource,
@InjectRepository(Order) private orderRepo: Repository<Order>,
@InjectRepository(Product) private productRepo: Repository<Product>,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
return this.dataSource.transaction(async manager => {
// Tout se passe dans la transaction
const product = await manager.findOneOrFail(Product, {
where: { id: dto.productId },
lock: { mode: 'pessimistic_write' },
});
if (product.stock < dto.quantity) {
throw new BadRequestException('Stock insuffisant');
}
// Diminuer le stock
await manager.update(Product, product.id, {
stock: product.stock - dto.quantity,
});
// Créer la commande
const order = manager.create(Order, {
...dto,
total: product.price * dto.quantity,
});
return manager.save(order);
// Si une erreur survient, toute la transaction est annulée
});
}
}