Как создать категории с подкатегориями nestjs, prisma, postgresql или recursive relationships with prisma?

Создание иерархических категорий с подкатегориями в 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();
  });

  // Добавьте тесты для всех методов
});

Заключение

Это комплексное руководство охватывает различные подходы к реализации иерархических категорий:

  1. Самоссылающиеся отношения - простой подход, но может быть неэффективен для глубоких иерархий
  2. Materialized Path - эффективен для запросов, но требует поддержки целостности данных
  3. Closure Table - наиболее гибкий подход для сложных операций с иерархиями
  4. Рекурсивные CTE - мощный инструмент PostgreSQL для работы с иерархическими данными

Выбор подхода зависит от ваших конкретных требований:

  • Для простых иерархий достаточно самоссылающихся отношений
  • Для часто меняющихся данных лучше подходит Closure Table
  • Для read-heavy приложений эффективен Materialized Path

Все подходы совместимы с NestJS, Prisma и PostgreSQL и предоставляют различные уровни производительности и гибкости.