NestJS + Prisma: สแตกแบ็กเอนด์สมัยใหม่สำหรับ Node.js

คู่มือฉบับสมบูรณ์ในการสร้าง API แบ็กเอนด์สมัยใหม่ด้วย NestJS และ Prisma ครอบคลุมการตั้งค่า โมเดล เซอร์วิส ทรานแซกชัน และแนวปฏิบัติที่ดี

NestJS และ Prisma สำหรับการสร้างสแตกแบ็กเอนด์สมัยใหม่

NestJS และ Prisma เป็นการผสมผสานที่ทรงพลังสำหรับการพัฒนาแบ็กเอนด์สมัยใหม่ NestJS มอบสถาปัตยกรรมแบบโมดูลและการฉีดดีเพนเดนซี ในขณะที่ Prisma มอบ ORM ที่ปลอดภัยด้านชนิดพร้อมประสบการณ์นักพัฒนาที่ยอดเยี่ยม สแตกนี้ช่วยให้สร้าง API ที่แข็งแรงและบำรุงรักษาง่ายได้

เหตุผลของการจับคู่นี้

Prisma สร้างไคลเอนต์ TypeScript ที่มีชนิดข้อมูลโดยอัตโนมัติจากสคีมาฐานข้อมูล เมื่อรวมกับ NestJS และระบบโมดูล โค้ดจะอธิบายตัวเองได้และข้อผิดพลาดด้านชนิดจะถูกตรวจพบตั้งแต่ตอนคอมไพล์

การตั้งค่าโปรเจกต์เริ่มต้น

การรวม Prisma เข้ากับโปรเจกต์ NestJS ต้องผ่านขั้นตอนการตั้งค่าเพียงไม่กี่ขั้นตอน กระบวนการเป็นมาตรฐานและมีเอกสารครบถ้วน

bash
# terminal
# Create a new NestJS project
nest new my-backend-api
cd my-backend-api

# Install Prisma as a dev dependency
npm install prisma --save-dev

# Install the Prisma client
npm install @prisma/client

# Initialize Prisma with PostgreSQL
npx prisma init --datasource-provider postgresql

คำสั่งนี้จะสร้างโฟลเดอร์ prisma/ พร้อมไฟล์ schema.prisma และไฟล์ .env สำหรับตัวแปรสภาพแวดล้อม

src/prisma/prisma.service.tstypescript
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  // Dedicated logger for Prisma operations
  private readonly logger = new Logger(PrismaService.name);

  constructor() {
    // Configure client with logging in development
    super({
      log: process.env.NODE_ENV === 'development'
        ? ['query', 'info', 'warn', 'error']
        : ['error'],
    });
  }

  // Automatic connection on module startup
  async onModuleInit() {
    await this.$connect();
    this.logger.log('Prisma connection established');
  }

  // Clean disconnection on application shutdown
  async onModuleDestroy() {
    await this.$disconnect();
    this.logger.log('Prisma connection closed');
  }
}

เซอร์วิส Prisma พร้อมที่จะถูกฉีดเข้าไปยังเซอร์วิสอื่น ๆ ของแอปพลิเคชันแล้ว

การสร้างโมดูล Prisma แบบ Global

เพื่อทำให้เซอร์วิส Prisma ใช้งานได้ทั่วทั้งแอปพลิเคชันโดยไม่ต้องนำเข้าอย่างชัดเจนในแต่ละโมดูล จึงใช้เดคอเรเตอร์ @Global()

src/prisma/prisma.module.tstypescript
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

// The @Global decorator makes this module available everywhere
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

การนำเข้าในโมดูลรากทำให้เซอร์วิสพร้อมใช้งานโดยอัตโนมัติ

src/app.module.tstypescript
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
import { PostsModule } from './posts/posts.module';

@Module({
  imports: [
    PrismaModule,   // Single declaration is sufficient
    UsersModule,
    PostsModule,
  ],
})
export class AppModule {}

ตอนนี้เซอร์วิสใดก็สามารถฉีด PrismaService ได้โดยไม่ต้องนำเข้าเพิ่มเติม

โมดูล Global เทียบกับการนำเข้าแบบชัดเจน

โมดูล Global ช่วยลดความซับซ้อนของสถาปัตยกรรม แต่ทำให้ดีเพนเดนซีกลายเป็นแบบซ่อน สำหรับแอปพลิเคชันขนาดเล็กก็ยอมรับได้ ในโปรเจกต์ขนาดใหญ่ การนำเข้าแบบชัดเจนช่วยให้ติดตามดีเพนเดนซีได้ง่ายขึ้น

การกำหนดสคีมา Prisma

ไฟล์ schema.prisma กำหนดโมเดลข้อมูล ความสัมพันธ์ และตัวเลือกของฐานข้อมูล Prisma ใช้ภาษานิยามสคีมาของตัวเอง (PSL)

prisma/schema.prismaprisma
generator client {
  provider = "prisma-client-js"
  // Enable types for advanced filtering queries
  previewFeatures = ["fullTextSearch"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// User model with all relations
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  password  String
  name      String
  role      Role     @default(USER)
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // One-to-many relations
  posts     Post[]
  comments  Comment[]
  profile   Profile?

  // Index for frequent searches
  @@index([email])
  @@map("users")
}

// Enum for user roles
enum Role {
  USER
  ADMIN
  MODERATOR
}

// One-to-one relation with User
model Profile {
  id       String  @id @default(cuid())
  bio      String?
  avatar   String?
  website  String?
  userId   String  @unique @map("user_id")

  user     User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("profiles")
}

// Posts with many-to-one relation to User
model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String
  excerpt     String?
  published   Boolean   @default(false)
  publishedAt DateTime? @map("published_at")
  authorId    String    @map("author_id")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  // Relations
  author      User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  comments    Comment[]
  categories  CategoriesOnPosts[]

  @@index([authorId])
  @@index([slug])
  @@map("posts")
}

// Comments with dual relation
model Comment {
  id        String   @id @default(cuid())
  content   String
  postId    String   @map("post_id")
  authorId  String   @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")

  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)

  @@index([postId])
  @@map("comments")
}

// Categories for posts
model Category {
  id    String @id @default(cuid())
  name  String @unique
  slug  String @unique

  posts CategoriesOnPosts[]

  @@map("categories")
}

// Pivot table for many-to-many relation
model CategoriesOnPosts {
  postId     String   @map("post_id")
  categoryId String   @map("category_id")
  assignedAt DateTime @default(now()) @map("assigned_at")

  post       Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  category   Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)

  @@id([postId, categoryId])
  @@map("categories_on_posts")
}

หลังจากแก้ไขสคีมาแล้ว มายเกรชันจะนำการเปลี่ยนแปลงไปใช้กับฐานข้อมูล

bash
# terminal
# Create a migration with descriptive name
npx prisma migrate dev --name init_schema

# Generate Prisma client (automatic after migrate dev)
npx prisma generate

# View schema in browser
npx prisma studio

การพัฒนาเซอร์วิสผู้ใช้ด้วย Prisma

เซอร์วิส Users แสดงให้เห็นถึงการดำเนินการ CRUD ทั่วไปกับ Prisma การกำหนดชนิดอัตโนมัติช่วยให้โค้ดและฐานข้อมูลสอดคล้องกัน

src/users/users.service.tstypescript
import {
  Injectable,
  NotFoundException,
  ConflictException,
  BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User, Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';

// Type for results without password
type SafeUser = Omit<User, 'password'>;

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  // Default selection without password
  private readonly safeSelect: Prisma.UserSelect = {
    id: true,
    email: true,
    name: true,
    role: true,
    createdAt: true,
    updatedAt: true,
    profile: true,
  };

  async create(createUserDto: CreateUserDto): Promise<SafeUser> {
    // Check email uniqueness
    const existingUser = await this.prisma.user.findUnique({
      where: { email: createUserDto.email },
    });

    if (existingUser) {
      throw new ConflictException('This email is already in use');
    }

    // Secure password hashing
    const hashedPassword = await bcrypt.hash(createUserDto.password, 12);

    // Creation with optional profile
    return this.prisma.user.create({
      data: {
        email: createUserDto.email,
        password: hashedPassword,
        name: createUserDto.name,
        // Nested profile creation if provided
        profile: createUserDto.bio ? {
          create: {
            bio: createUserDto.bio,
          },
        } : undefined,
      },
      select: this.safeSelect,
    });
  }

  async findAll(params: {
    page?: number;
    limit?: number;
    search?: string;
  }): Promise<{ data: SafeUser[]; total: number; pages: number }> {
    const { page = 1, limit = 10, search } = params;
    const skip = (page - 1) * limit;

    // Optional search condition
    const where: Prisma.UserWhereInput = search
      ? {
          OR: [
            { email: { contains: search, mode: 'insensitive' } },
            { name: { contains: search, mode: 'insensitive' } },
          ],
        }
      : {};

    // Parallel execution for performance
    const [data, total] = await this.prisma.$transaction([
      this.prisma.user.findMany({
        where,
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' },
        select: this.safeSelect,
      }),
      this.prisma.user.count({ where }),
    ]);

    return {
      data,
      total,
      pages: Math.ceil(total / limit),
    };
  }

  async findOne(id: string): Promise<SafeUser> {
    const user = await this.prisma.user.findUnique({
      where: { id },
      select: {
        ...this.safeSelect,
        // Include recent posts
        posts: {
          take: 5,
          orderBy: { createdAt: 'desc' },
          select: {
            id: true,
            title: true,
            slug: true,
            published: true,
          },
        },
      },
    });

    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }

    return user;
  }

  async update(id: string, updateUserDto: UpdateUserDto): Promise<SafeUser> {
    // Check existence
    await this.findOne(id);

    // Update with nested profile handling
    return this.prisma.user.update({
      where: { id },
      data: {
        name: updateUserDto.name,
        // Update or create profile
        profile: updateUserDto.bio ? {
          upsert: {
            create: { bio: updateUserDto.bio },
            update: { bio: updateUserDto.bio },
          },
        } : undefined,
      },
      select: this.safeSelect,
    });
  }

  async remove(id: string): Promise<void> {
    await this.findOne(id);
    // Deletion cascades to profile and posts
    await this.prisma.user.delete({ where: { id } });
  }
}

การกำหนดชนิดของ Prisma ช่วยรับประกันว่าทุกคุณสมบัติที่ใช้มีอยู่ในสคีมา

พร้อมที่จะพิชิตการสัมภาษณ์ Node.js / NestJS แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

การจัดการความสัมพันธ์ด้วย Prisma

Prisma ทำให้การจัดการความสัมพันธ์ที่ซับซ้อนเป็นเรื่องง่าย คิวรีแบบซ้อนช่วยให้โหลดข้อมูลที่เกี่ยวข้องได้ในคำขอครั้งเดียว

src/posts/posts.service.tstypescript
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Prisma } from '@prisma/client';

@Injectable()
export class PostsService {
  constructor(private readonly prisma: PrismaService) {}

  async create(authorId: string, createPostDto: CreatePostDto) {
    // Automatic slug generation from title
    const slug = this.generateSlug(createPostDto.title);

    return this.prisma.post.create({
      data: {
        title: createPostDto.title,
        slug,
        content: createPostDto.content,
        excerpt: createPostDto.excerpt,
        // Connect to existing author
        author: {
          connect: { id: authorId },
        },
        // Connect to existing categories
        categories: createPostDto.categoryIds ? {
          create: createPostDto.categoryIds.map(categoryId => ({
            category: { connect: { id: categoryId } },
          })),
        } : undefined,
      },
      include: {
        author: {
          select: { id: true, name: true },
        },
        categories: {
          include: {
            category: true,
          },
        },
      },
    });
  }

  async findAllPublished(params: {
    page?: number;
    limit?: number;
    categorySlug?: string;
  }) {
    const { page = 1, limit = 10, categorySlug } = params;
    const skip = (page - 1) * limit;

    // Conditional filter by category
    const where: Prisma.PostWhereInput = {
      published: true,
      ...(categorySlug && {
        categories: {
          some: {
            category: { slug: categorySlug },
          },
        },
      }),
    };

    const [posts, total] = await this.prisma.$transaction([
      this.prisma.post.findMany({
        where,
        skip,
        take: limit,
        orderBy: { publishedAt: 'desc' },
        include: {
          author: {
            select: { id: true, name: true },
          },
          categories: {
            include: {
              category: { select: { name: true, slug: true } },
            },
          },
          _count: {
            select: { comments: true },
          },
        },
      }),
      this.prisma.post.count({ where }),
    ]);

    return { posts, total, pages: Math.ceil(total / limit) };
  }

  async findBySlug(slug: string) {
    const post = await this.prisma.post.findUnique({
      where: { slug },
      include: {
        author: {
          select: { id: true, name: true, profile: true },
        },
        categories: {
          include: {
            category: true,
          },
        },
        comments: {
          orderBy: { createdAt: 'desc' },
          take: 20,
          include: {
            author: {
              select: { id: true, name: true },
            },
          },
        },
      },
    });

    if (!post) {
      throw new NotFoundException(`Post "${slug}" not found`);
    }

    return post;
  }

  async publish(id: string, authorId: string) {
    // Verify author is the owner
    const post = await this.prisma.post.findUnique({
      where: { id },
      select: { authorId: true },
    });

    if (!post) {
      throw new NotFoundException(`Post with ID ${id} not found`);
    }

    if (post.authorId !== authorId) {
      throw new ForbiddenException('Publication not authorized');
    }

    return this.prisma.post.update({
      where: { id },
      data: {
        published: true,
        publishedAt: new Date(),
      },
    });
  }

  private generateSlug(title: string): string {
    return title
      .toLowerCase()
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }
}

include ช่วยให้ดึงความสัมพันธ์เชิงลึกได้พร้อมควบคุมว่าจะส่งคืนฟิลด์ใดบ้าง

ทรานแซกชันและการดำเนินงานแบบอะตอมิก

Prisma มีหลายวิธีในการรับประกันความเป็นอะตอมิกของการดำเนินงาน ทรานแซกชันแบบอินเทอร์แอคทีฟให้ความยืดหยุ่นมากที่สุด

src/orders/orders.service.tstypescript
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';

@Injectable()
export class OrdersService {
  constructor(private readonly prisma: PrismaService) {}

  async createOrder(userId: string, createOrderDto: CreateOrderDto) {
    // Interactive transaction to ensure atomicity
    return this.prisma.$transaction(async (tx) => {
      // 1. Verify stock for each item
      const items = await Promise.all(
        createOrderDto.items.map(async (item) => {
          const product = await tx.product.findUnique({
            where: { id: item.productId },
          });

          if (!product) {
            throw new BadRequestException(
              `Product ${item.productId} not found`
            );
          }

          if (product.stock < item.quantity) {
            throw new BadRequestException(
              `Insufficient stock for ${product.name}`
            );
          }

          return { product, quantity: item.quantity };
        })
      );

      // 2. Calculate total
      const total = items.reduce(
        (sum, { product, quantity }) => sum + product.price * quantity,
        0
      );

      // 3. Create order
      const order = await tx.order.create({
        data: {
          userId,
          total,
          status: 'PENDING',
          items: {
            create: items.map(({ product, quantity }) => ({
              productId: product.id,
              quantity,
              price: product.price,
            })),
          },
        },
        include: {
          items: {
            include: { product: true },
          },
        },
      });

      // 4. Update stock
      await Promise.all(
        items.map(({ product, quantity }) =>
          tx.product.update({
            where: { id: product.id },
            data: { stock: { decrement: quantity } },
          })
        )
      );

      return order;
    });
  }

  async cancelOrder(orderId: string, userId: string) {
    return this.prisma.$transaction(async (tx) => {
      // Get order with items
      const order = await tx.order.findUnique({
        where: { id: orderId },
        include: { items: true },
      });

      if (!order || order.userId !== userId) {
        throw new BadRequestException('Order not found');
      }

      if (order.status !== 'PENDING') {
        throw new BadRequestException(
          'Only pending orders can be cancelled'
        );
      }

      // Restore stock
      await Promise.all(
        order.items.map((item) =>
          tx.product.update({
            where: { id: item.productId },
            data: { stock: { increment: item.quantity } },
          })
        )
      );

      // Update status
      return tx.order.update({
        where: { id: orderId },
        data: { status: 'CANCELLED' },
      });
    });
  }
}

ทรานแซกชันรับประกันว่าทุกการดำเนินงานจะสำเร็จร่วมกันหรือล้มเหลวร่วมกัน

ระยะหมดเวลาของทรานแซกชัน

โดยค่าเริ่มต้น ทรานแซกชันของ Prisma มีระยะหมดเวลา 5 วินาที สำหรับการดำเนินงานที่ใช้เวลานาน สามารถปรับได้ด้วย $transaction([...], { timeout: 10000 })

มิดเดิลแวร์ของ Prisma สำหรับการตรวจสอบ

มิดเดิลแวร์ของ Prisma ช่วยให้สามารถดักจับคิวรีเพื่อเพิ่มพฤติกรรมแบบขวาง เช่น การตรวจสอบหรือ soft delete

src/prisma/prisma.service.ts (version with middleware)typescript
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient, Prisma } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(PrismaService.name);

  constructor() {
    super({
      log: [
        { level: 'query', emit: 'event' },
        { level: 'error', emit: 'stdout' },
      ],
    });

    // Middleware for modification auditing
    this.$use(async (params: Prisma.MiddlewareParams, next) => {
      const start = Date.now();

      // Execute query
      const result = await next(params);

      const duration = Date.now() - start;

      // Log slow queries (> 100ms)
      if (duration > 100) {
        this.logger.warn(
          `Slow query: ${params.model}.${params.action} - ${duration}ms`
        );
      }

      // Audit write operations
      if (['create', 'update', 'delete'].includes(params.action)) {
        this.logger.log(
          `Audit: ${params.action} on ${params.model} - ${duration}ms`
        );
      }

      return result;
    });

    // Middleware for automatic soft delete
    this.$use(async (params, next) => {
      // Transform delete to update for certain models
      if (params.model === 'User' && params.action === 'delete') {
        params.action = 'update';
        params.args['data'] = { deletedAt: new Date() };
      }

      // Automatic exclusion of deleted records
      if (params.model === 'User' && params.action === 'findMany') {
        if (!params.args) params.args = {};
        if (!params.args.where) params.args.where = {};
        params.args.where.deletedAt = null;
      }

      return next(params);
    });
  }

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

มิดเดิลแวร์จะทำงานตามลำดับที่ลงทะเบียนและสามารถแก้ไขพารามิเตอร์ของคิวรีได้

การปรับปรุงประสิทธิภาพด้วย Prisma

มีหลายเทคนิคที่ช่วยปรับปรุงประสิทธิภาพของคิวรี Prisma ในแอปพลิเคชัน NestJS

src/common/prisma-extensions.tstypescript
import { Prisma, PrismaClient } from '@prisma/client';

// Extension for standardized pagination
export const paginationExtension = Prisma.defineExtension({
  model: {
    $allModels: {
      async paginate<T, A>(
        this: T,
        args: Prisma.Exact<A, Prisma.Args<T, 'findMany'>> & {
          page?: number;
          limit?: number;
        }
      ): Promise<{
        data: Prisma.Result<T, A, 'findMany'>;
        meta: { page: number; limit: number; total: number; pages: number };
      }> {
        const { page = 1, limit = 10, ...rest } = args as any;
        const skip = (page - 1) * limit;

        const context = Prisma.getExtensionContext(this);
        const [data, total] = await Promise.all([
          (context as any).findMany({ ...rest, skip, take: limit }),
          (context as any).count({ where: (rest as any).where }),
        ]);

        return {
          data,
          meta: {
            page,
            limit,
            total,
            pages: Math.ceil(total / limit),
          },
        };
      },
    },
  },
});

// Usage in service
// const result = await this.prisma.$extends(paginationExtension)
//   .user.paginate({ page: 2, limit: 20, where: { role: 'USER' } });

สำหรับคิวรีที่เรียกใช้บ่อย แคชจะช่วยปรับปรุงเวลาตอบสนองอย่างมีนัยสำคัญ

src/posts/posts.service.ts (with cache)typescript
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class PostsService {
  constructor(
    private readonly prisma: PrismaService,
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}

  async findPopularPosts() {
    const cacheKey = 'posts:popular';

    // Try to get from cache
    const cached = await this.cacheManager.get(cacheKey);
    if (cached) {
      return cached;
    }

    // Database query
    const posts = await this.prisma.post.findMany({
      where: { published: true },
      orderBy: { comments: { _count: 'desc' } },
      take: 10,
      include: {
        author: { select: { name: true } },
        _count: { select: { comments: true } },
      },
    });

    // Cache for 5 minutes
    await this.cacheManager.set(cacheKey, posts, 300000);

    return posts;
  }

  async invalidateCache(postId: string) {
    // Selective cache invalidation
    await this.cacheManager.del('posts:popular');
    await this.cacheManager.del(`post:${postId}`);
  }
}

การทดสอบด้วย Prisma และ NestJS

การทดสอบต้องใช้กลยุทธ์ฐานข้อมูลแบบแยกอิสระ การใช้ฐานข้อมูลเฉพาะสำหรับการทดสอบช่วยรับประกันความสามารถในการทำซ้ำ

test/helpers/prisma-test.helper.tstypescript
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';

const prisma = new PrismaClient();

export async function setupTestDatabase() {
  // Use a test database
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;

  // Apply migrations
  execSync('npx prisma migrate deploy', {
    env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL },
  });
}

export async function cleanupTestDatabase() {
  // Delete all data in dependency order
  const tablenames = await prisma.$queryRaw<Array<{ tablename: string }>>`
    SELECT tablename FROM pg_tables WHERE schemaname='public'
  `;

  for (const { tablename } of tablenames) {
    if (tablename !== '_prisma_migrations') {
      await prisma.$executeRawUnsafe(
        `TRUNCATE TABLE "public"."${tablename}" CASCADE;`
      );
    }
  }
}

export async function disconnectTestDatabase() {
  await prisma.$disconnect();
}

การทดสอบเชิงรวมใช้ตัวช่วยเหล่านี้เพื่อรับประกันสภาพแวดล้อมที่สะอาด

test/users.e2e-spec.tstypescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { cleanupTestDatabase, setupTestDatabase } from './helpers/prisma-test.helper';

describe('UsersController (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    await setupTestDatabase();

    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

    prisma = app.get(PrismaService);
    await app.init();
  });

  beforeEach(async () => {
    await cleanupTestDatabase();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /users', () => {
    it('should create a new user', async () => {
      const createUserDto = {
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User',
      };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(201);

      expect(response.body).toMatchObject({
        email: createUserDto.email,
        name: createUserDto.name,
      });
      expect(response.body.password).toBeUndefined();
    });

    it('should reject duplicate email', async () => {
      const createUserDto = {
        email: 'duplicate@example.com',
        password: 'Password123!',
        name: 'First User',
      };

      await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(201);

      await request(app.getHttpServer())
        .post('/users')
        .send({ ...createUserDto, name: 'Second User' })
        .expect(409);
    });
  });
});

บทสรุป

NestJS และ Prisma ก่อให้เกิดสแตกแบ็กเอนด์ที่ทันสมัยและมีผลิตภาพสูง การกำหนดชนิดอัตโนมัติ มายเกรชันแบบประกาศ และการรวมเข้ากับ NestJS แบบเนทีฟช่วยให้พัฒนา API ที่แข็งแรงได้อย่างรวดเร็ว

เช็กลิสต์สำหรับการรวม NestJS + Prisma ที่ประสบความสำเร็จ

  • ✅ โมดูล Prisma แบบ Global เพื่อทำให้การฉีดง่ายขึ้น
  • ✅ สคีมา Prisma พร้อมความสัมพันธ์และดัชนีที่เหมาะสม
  • ✅ เซอร์วิสแบบกำหนดชนิดพร้อมการเลือกฟิลด์อย่างชัดเจน
  • ✅ ทรานแซกชันสำหรับการดำเนินงานแบบอะตอมิก
  • ✅ มิดเดิลแวร์สำหรับการตรวจสอบและ soft delete
  • ✅ แคชสำหรับคิวรีที่เรียกใช้บ่อย
  • ✅ การทดสอบเชิงรวมพร้อมฐานข้อมูลแยกอิสระ
  • ✅ การแบ่งหน้ามาตรฐานผ่าน extensions

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

การผสมผสานนี้ใช้จุดแข็งของแต่ละเครื่องมือ ได้แก่ สถาปัตยกรรมโมดูลของ NestJS สำหรับโครงสร้าง และ Prisma สำหรับชั้นข้อมูลที่ปลอดภัยด้านชนิด ผลลัพธ์คือโค้ดที่บำรุงรักษาง่าย ทดสอบได้ และมีประสิทธิภาพสูง เหมาะสำหรับแอปพลิเคชันระดับองค์กร

แท็ก

#nestjs
#prisma
#nodejs
#typescript
#backend

แชร์

บทความที่เกี่ยวข้อง