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
    });
  }
}
✏️ Exercices du module ▶ Mini-projet Module 05 →