Создание иерархических категорий с подкатегориями в NestJS, Prisma и PostgreSQL
В этом подробном руководстве я расскажу о нескольких подходах к реализации рекурсивных отношений (иерархических структур) с использованием NestJS, Prisma и PostgreSQL.
1. Моделирование иерархической структуры в Prisma
Вариант 1: Самоссылающиеся отношения (Adjacency List)
// schema.prisma model Category { id Int @id @default(autoincrement()) name String slug String @unique parentId Int? // Ссылка на родительскую категорию parent Category? @relation("CategoryToCategory", fields: [parentId], references: [id]) children Category[] @relation("CategoryToCategory") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("categories") }
Вариант 2: Materialized Path (для более эффективных запросов)
model Category { id Int @id @default(autoincrement()) name String slug String @unique path String // Путь в формате "1/2/3" depth Int // Уровень вложенности createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("categories") }
Вариант 3: Closure Table (для сложных иерархий)
model Category { id Int @id @default(autoincrement()) name String slug String @unique ancestors CategoryClosure[] @relation("CategoryAncestors") descendants CategoryClosure[] @relation("CategoryDescendants") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("categories") } model CategoryClosure { ancestorId Int ancestor Category @relation("CategoryAncestors", fields: [ancestorId], references: [id]) descendantId Int descendant Category @relation("CategoryDescendants", fields: [descendantId], references: [id]) depth Int @@id([ancestorId, descendantId]) @@map("category_closure") }
2. Сервис для работы с категориями
// categories/categories.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; @Injectable() export class CategoriesService { constructor(private prisma: PrismaService) {} // Создание категории async create(createCategoryDto: CreateCategoryDto) { const { parentId, ...categoryData } = createCategoryDto; if (parentId) { // Проверяем существование родительской категории const parent = await this.prisma.category.findUnique({ where: { id: parentId }, }); if (!parent) { throw new NotFoundException(`Parent category with ID ${parentId} not found`); } } return this.prisma.category.create({ data: { ...categoryData, parent: parentId ? { connect: { id: parentId } } : undefined, }, include: { parent: true, children: true, }, }); } // Получение всех категорий с вложенностью async findAll() { const categories = await this.prisma.category.findMany({ where: { parentId: null }, // Только корневые категории include: { children: { include: { children: true, // Включаем подкатегории второго уровня }, }, }, }); return this.buildTree(categories); } // Рекурсивное построение дерева private buildTree(categories: any[], parentId: number | null = null) { const tree = []; for (const category of categories) { if (category.parentId === parentId) { const children = this.buildTree(categories, category.id); if (children.length) { category.children = children; } tree.push(category); } } return tree; } // Получение полного пути категории async getCategoryPath(id: number): Promise<any[]> { const category = await this.prisma.category.findUnique({ where: { id }, include: { parent: true }, }); if (!category) { throw new NotFoundException(`Category with ID ${id} not found`); } const path = [category]; if (category.parent) { const parentPath = await this.getCategoryPath(category.parent.id); path.unshift(...parentPath); } return path; } // Получение всех потомков категории async getDescendants(id: number): Promise<any[]> { const category = await this.prisma.category.findUnique({ where: { id }, include: { children: true }, }); if (!category) { throw new NotFoundException(`Category with ID ${id} not found`); } const descendants = [...category.children]; for (const child of category.children) { const childDescendants = await this.getDescendants(child.id); descendants.push(...childDescendants); } return descendants; } // Обновление категории async update(id: number, updateCategoryDto: UpdateCategoryDto) { const { parentId, ...updateData } = updateCategoryDto; // Проверяем существование категории const category = await this.prisma.category.findUnique({ where: { id }, }); if (!category) { throw new NotFoundException(`Category with ID ${id} not found`); } if (parentId && parentId === id) { throw new Error('Category cannot be its own parent'); } return this.prisma.category.update({ where: { id }, data: { ...updateData, parent: parentId ? { connect: { id: parentId } } : undefined, }, include: { parent: true, children: true, }, }); } // Удаление категории async remove(id: number) { // Проверяем существование категории const category = await this.prisma.category.findUnique({ where: { id }, include: { children: true }, }); if (!category) { throw new NotFoundException(`Category with ID ${id} not found`); } // Если есть дочерние категории, удаляем их рекурсивно if (category.children.length > 0) { for (const child of category.children) { await this.remove(child.id); } } return this.prisma.category.delete({ where: { id }, }); } }
3. DTO (Data Transfer Objects)
// categories/dto/create-category.dto.ts import { IsString, IsOptional, IsInt } from 'class-validator'; export class CreateCategoryDto { @IsString() name: string; @IsString() slug: string; @IsOptional() @IsInt() parentId?: number; } // categories/dto/update-category.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateCategoryDto } from './create-category.dto'; export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
4. Контроллер
// categories/categories.controller.ts import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; import { CategoriesService } from './categories.service'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; @Controller('categories') export class CategoriesController { constructor(private readonly categoriesService: CategoriesService) {} @Post() create(@Body() createCategoryDto: CreateCategoryDto) { return this.categoriesService.create(createCategoryDto); } @Get() findAll() { return this.categoriesService.findAll(); } @Get(':id/path') getPath(@Param('id') id: string) { return this.categoriesService.getCategoryPath(+id); } @Get(':id/descendants') getDescendants(@Param('id') id: string) { return this.categoriesService.getDescendants(+id); } @Patch(':id') update(@Param('id') id: string, @Body() updateCategoryDto: UpdateCategoryDto) { return this.categoriesService.update(+id, updateCategoryDto); } @Delete(':id') remove(@Param('id') id: string) { return this.categoriesService.remove(+id); } }
5. Модуль категорий
// categories/categories.module.ts import { Module } from '@nestjs/common'; import { CategoriesService } from './categories.service'; import { CategoriesController } from './categories.controller'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ imports: [PrismaModule], controllers: [CategoriesController], providers: [CategoriesService], exports: [CategoriesService], }) export class CategoriesModule {}
6. Расширенная реализация с Materialized Path
Для более эффективных запросов можно использовать подход Materialized Path:
// categories/materialized-path.service.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class MaterializedPathService { constructor(private prisma: PrismaService) {} async createWithPath(name: string, slug: string, parentId?: number) { if (parentId) { const parent = await this.prisma.category.findUnique({ where: { id: parentId }, }); if (!parent) { throw new Error('Parent category not found'); } const path = `${parent.path}/${parent.id}`; const depth = parent.depth + 1; return this.prisma.category.create({ data: { name, slug, path, depth, }, }); } return this.prisma.category.create({ data: { name, slug, path: '', depth: 0, }, }); } async getDescendantsByPath(id: number) { const category = await this.prisma.category.findUnique({ where: { id }, }); if (!category) { throw new Error('Category not found'); } const searchPath = category.path ? `${category.path}/${category.id}` : `${category.id}`; return this.prisma.category.findMany({ where: { path: { startsWith: searchPath, }, }, orderBy: { path: 'asc', }, }); } async getAncestorsByPath(id: number) { const category = await this.prisma.category.findUnique({ where: { id }, }); if (!category) { throw new Error('Category not found'); } if (!category.path) { return []; } const ancestorIds = category.path.split('/').map(Number).filter(Boolean); return this.prisma.category.findMany({ where: { id: { in: ancestorIds, }, }, orderBy: { depth: 'asc', }, }); } }
7. Рекурсивные запросы с помощью PostgreSQL CTE
Для сложных иерархических запросов можно использовать рекурсивные CTE:
async getFullTree() { return this.prisma.$queryRaw` WITH RECURSIVE category_tree AS ( SELECT id, name, slug, parent_id, 1 as level FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.name, c.slug, c.parent_id, ct.level + 1 FROM categories c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM category_tree ORDER BY level, name; `; }
8. Тестирование
// categories/categories.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { CategoriesService } from './categories.service'; import { PrismaService } from '../prisma/prisma.service'; describe('CategoriesService', () => { let service: CategoriesService; let prisma: PrismaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CategoriesService, { provide: PrismaService, useValue: { category: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, }, }, ], }).compile(); service = module.get<CategoriesService>(CategoriesService); prisma = module.get<PrismaService>(PrismaService); }); it('should be defined', () => { expect(service).toBeDefined(); }); // Добавьте тесты для всех методов });
Заключение
Это комплексное руководство охватывает различные подходы к реализации иерархических категорий:
- Самоссылающиеся отношения - простой подход, но может быть неэффективен для глубоких иерархий
- Materialized Path - эффективен для запросов, но требует поддержки целостности данных
- Closure Table - наиболее гибкий подход для сложных операций с иерархиями
- Рекурсивные CTE - мощный инструмент PostgreSQL для работы с иерархическими данными
Выбор подхода зависит от ваших конкретных требований:
- Для простых иерархий достаточно самоссылающихся отношений
- Для часто меняющихся данных лучше подходит Closure Table
- Для read-heavy приложений эффективен Materialized Path
Все подходы совместимы с NestJS, Prisma и PostgreSQL и предоставляют различные уровни производительности и гибкости.