Exercices â Module 04
TypeORM & Base de données · 5 exercices pratiques
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;
}
}
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;
}
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);
}
}
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();
}
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);
});
}