Exercices — Module 04

TypeORM & Base de données · 5 exercices pratiques

Ex 01 ⭐ Facile

Entité Product complÚte

Créez une entité Product avec : id (UUID), name, description, price (decimal), stock, isAvailable, imageUrl, createdAt, updatedAt. Ajoutez un index sur le nom et une contrainte unique sur le SKU.

Voir la solution
@Entity('products')
@Index(['name'])
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ unique: true, length: 50 })
  sku: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  price: number;

  @Column({ type: 'int', default: 0 })
  stock: number;

  @Column({ default: true })
  isAvailable: boolean;

  @Column({ nullable: true })
  imageUrl: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @BeforeInsert()
  @BeforeUpdate()
  validateStock() {
    if (this.stock < 0) this.stock = 0;
    if (this.stock === 0) this.isAvailable = false;
  }
}
Ex 02 ⭐ Facile

Relations OneToMany / ManyToOne

Créez les entités Category et Product avec une relation ManyToOne (un produit appartient à une catégorie, une catégorie a plusieurs produits). Incluez la clé étrangÚre explicite.

Voir la solution
@Entity('categories')
export class Category {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true, length: 100 })
  name: string;

  @Column({ unique: true })
  slug: string;

  @OneToMany(() => Product, (product) => product.category)
  products: Product[];
}

@Entity('products')
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @ManyToOne(() => Category, (category) => category.products, {
    nullable: true,
    onDelete: 'SET NULL',
  })
  @JoinColumn({ name: 'category_id' })
  category: Category;

  @Column({ name: 'category_id', nullable: true })
  categoryId: number;
}
Ex 03 ⭐⭐ Moyen

Service CRUD avec Repository

Implémentez un ProductsService complet avec : findAll (avec pagination et filtres), findOne, create, update, remove (soft delete). Gérez les erreurs appropriées.

Voir la solution
@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepo: Repository<Product>,
  ) {}

  async findAll(page = 1, limit = 10, categoryId?: number) {
    const [items, total] = await this.productRepo.findAndCount({
      where: categoryId ? { categoryId, isAvailable: true } : { isAvailable: true },
      relations: { category: true },
      order: { createdAt: 'DESC' },
      take: limit,
      skip: (page - 1) * limit,
    });
    return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
  }

  async findOne(id: string): Promise<Product> {
    const product = await this.productRepo.findOne({
      where: { id },
      relations: { category: true },
    });
    if (!product) throw new NotFoundException(`Product ${id} not found`);
    return product;
  }

  async create(dto: CreateProductDto): Promise<Product> {
    const existing = await this.productRepo.findOneBy({ sku: dto.sku });
    if (existing) throw new ConflictException(`SKU ${dto.sku} already in use`);
    return this.productRepo.save(this.productRepo.create(dto));
  }

  async update(id: string, dto: UpdateProductDto): Promise<Product> {
    await this.findOne(id);
    await this.productRepo.update(id, dto);
    return this.findOne(id);
  }

  async remove(id: string): Promise<void> {
    await this.findOne(id);
    await this.productRepo.softDelete(id);
  }
}
Ex 04 ⭐⭐ Moyen

Recherche avec QueryBuilder

Implémentez une méthode search(query, minPrice, maxPrice) avec QueryBuilder. La recherche doit s'appliquer sur le nom ET la description (ILIKE), avec un filtre de prix optionnel.

Voir la solution
async search(query: string, minPrice?: number, maxPrice?: number): Promise<Product[]> {
  const qb = this.productRepo
    .createQueryBuilder('product')
    .leftJoinAndSelect('product.category', 'category')
    .where('product.isAvailable = :available', { available: true });

  if (query) {
    qb.andWhere(
      new Brackets(b => {
        b.where('product.name ILIKE :q', { q: `%${query}%` })
         .orWhere('product.description ILIKE :q', { q: `%${query}%` });
      })
    );
  }

  if (minPrice !== undefined) {
    qb.andWhere('product.price >= :min', { min: minPrice });
  }

  if (maxPrice !== undefined) {
    qb.andWhere('product.price <= :max', { max: maxPrice });
  }

  return qb.orderBy('product.name', 'ASC').getMany();
}
Ex 05 ⭐⭐⭐ Difficile

Transaction multi-entités

Implémentez une méthode purchaseProduct(productId, quantity, userId) qui décrémente le stock ET crée un enregistrement de vente dans une seule transaction atomique.

Voir la solution
async purchaseProduct(productId: string, quantity: number, userId: number): Promise<Sale> {
  return this.dataSource.transaction(async manager => {
    const product = await manager.findOne(Product, {
      where: { id: productId },
      lock: { mode: 'pessimistic_write' },
    });

    if (!product) throw new NotFoundException('Product not found');
    if (!product.isAvailable) throw new BadRequestException('Product unavailable');
    if (product.stock < quantity) {
      throw new BadRequestException(`Only ${product.stock} items left in stock`);
    }

    // Décrémentation atomique du stock
    await manager.update(Product, productId, {
      stock: product.stock - quantity,
      isAvailable: (product.stock - quantity) > 0,
    });

    // Création de la vente
    const sale = manager.create(Sale, {
      productId,
      userId,
      quantity,
      unitPrice: product.price,
      total: Number(product.price) * quantity,
      purchasedAt: new Date(),
    });

    return manager.save(sale);
  });
}
← Cours ▶ Mini-projet Module 05 →