Skip to content

Chapter 3 - PostgreSQL dan TypeORM

Ikhtisar Chapter

Chapter ini mengubah aplikasi dari penyimpanan sementara di memory menjadi aplikasi yang menyimpan data secara permanen di PostgreSQL. TypeORM dipakai sebagai jembatan antara kode TypeScript dan database relasional.

Peta Cepat

FokusPostgreSQL, Docker, TypeORM, entity, repository
Perubahan BesarService tidak lagi membaca array, tetapi memanggil repository async
Hasil AkhirCRUD task berjalan dengan database dan query builder

Gambaran Besar

Pada chapter sebelumnya, data disimpan dalam array. Cara itu cocok untuk belajar alur NestJS, tetapi tidak cukup untuk aplikasi nyata. Begitu server restart, semua data hilang. Persistensi berarti data tetap ada karena disimpan di database.

Kursus menggunakan PostgreSQL sebagai database relasional dan TypeORM sebagai ORM. Dengan ORM, kita mendefinisikan entity sebagai class TypeScript, lalu menggunakan repository untuk melakukan operasi database tanpa menulis SQL mentah di setiap tempat.

Alur Persistensi

text
Controller
  |
  v
TasksService
  |
  v
TasksRepository
  |
  v
TypeORM Query atau Repository Method
  |
  v
PostgreSQL

Struktur Direktori

Chapter ini menambahkan beberapa file baru dan mengubah penyimpanan data dari array ke database:

src/
├── main.ts
├── app.module.ts                  ← DIUBAH: + TypeOrmModule.forRoot()
└── tasks/
    ├── dto/
    │   ├── create-task.dto.ts
    │   ├── get-tasks-filter.dto.ts
    │   └── update-task-status.dto.ts
    ├── task.entity.ts             ← BARU: class Task dengan @Entity, @Column
    │                                       menggantikan task.model.ts (interface)
    ├── task-status.enum.ts        ← tetap ada, dipakai entity dan repository
    ├── tasks.controller.ts
    ├── tasks.module.ts            ← DIUBAH: + TypeOrmModule.forFeature([Task])
    ├── tasks.repository.ts        ← BARU: extends Repository<Task>
    └── tasks.service.ts           ← DIUBAH: semua method jadi async/await

Package Baru

bash
yarn add @nestjs/typeorm typeorm pg

Tiga package ini diperlukan: @nestjs/typeorm untuk integrasi NestJS, typeorm untuk ORM-nya sendiri, pg untuk driver PostgreSQL.

Ringkasan Lecture

1. Introduction to Persistence

Persistensi artinya data tetap ada setelah server restart. Sebelum chapter ini, semua task disimpan di array dalam memori — begitu server mati, semua data hilang. Ini tidak cocok untuk aplikasi nyata.

PostgreSQL dipilih sebagai database relasional. Alasan umum memilih relasional: data terstruktur dengan relasi antar tabel yang terdefinisi, transaksi ACID tersedia, dan dukungan komunitas sangat besar.

TypeORM berfungsi sebagai jembatan antara TypeScript dan PostgreSQL. Daripada menulis SQL mentah di setiap method, kita mendefinisikan entity sebagai class TypeScript, dan TypeORM menerjemahkannya ke operasi database.

2. Running PostgreSQL via Docker

Daripada menginstal PostgreSQL langsung, Docker dipakai agar setup lebih bersih dan dapat diulang di mesin mana pun:

bash
docker run \
  --name postgres-nest \
  -p 5432:5432 \
  -e POSTGRES_PASSWORD=postgres \
  -d postgres

Penjelasan flag:

  • --name postgres-nest — nama container untuk memudahkan manajemen
  • -p 5432:5432 — port host ke port container (format host:container)
  • -e POSTGRES_PASSWORD=postgres — set password user postgres
  • -d — jalankan sebagai daemon (background)

Verifikasi container berjalan:

bash
docker ps
# CONTAINER ID   IMAGE     ...   PORTS                    NAMES
# abc123         postgres  ...   0.0.0.0:5432->5432/tcp   postgres-nest

3. Setting up pgAdmin

pgAdmin adalah GUI untuk mengelola database PostgreSQL. Setelah pgAdmin dibuka di browser, tambahkan koneksi baru dengan konfigurasi:

Host: localhost
Port: 5432
Username: postgres
Password: postgres

pgAdmin membantu melihat tabel yang dibuat, menjalankan query SQL manual, dan memeriksa data saat debugging.

4. Creating a Database using pgAdmin

Buat database baru bernama task-management melalui pgAdmin. Nama ini akan dipakai di konfigurasi TypeORM. Satu PostgreSQL instance dapat menampung banyak database — tiap proyek biasanya punya database sendiri.

5. Introduction to TypeORM

TypeORM adalah ORM (Object Relational Mapper) untuk Node.js dan TypeScript. Dengan ORM:

  • Tabel database direpresentasikan sebagai class TypeScript (entity)
  • Kolom direpresentasikan sebagai property dengan decorator
  • Query dibuat melalui method atau query builder, bukan string SQL mentah
  • TypeScript mendeteksi kesalahan property lebih awal saat compile time

Kekurangan yang perlu diingat: meski ORM menyederhanakan banyak hal, pemahaman SQL tetap perlu agar query yang dihasilkan efisien dan tidak N+1.

6. Setting up a Database Connection

Install tiga package yang dibutuhkan:

bash
yarn add @nestjs/typeorm typeorm pg

Daftarkan TypeOrmModule di AppModule:

typescript
// src/app.module.ts
import { TypeOrmModule } from '@nestjs/typeorm'

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'postgres',
      database: 'task-management',
      autoLoadEntities: true,   // entity yang didaftarkan lewat forFeature() otomatis dimuat
      synchronize: true,        // schema disesuaikan otomatis — JANGAN di production!
    }),
    TasksModule,
  ],
})
export class AppModule {}

synchronize: true

synchronize: true secara otomatis mengubah schema database sesuai entity. Di development ini sangat nyaman, tetapi di production bisa menghapus kolom atau data yang tidak terduga. Gunakan migrasi di production.

7. Creating a Task Entity

Task diubah dari interface menjadi entity TypeORM. Decorator dari typeorm mendefinisikan tabel dan kolomnya:

typescript
// src/tasks/task.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { TaskStatus } from './task-status.enum'

@Entity()                    // nama tabel default: 'task' (lowercase class name)
export class Task {
  @PrimaryGeneratedColumn('uuid')  // UUID dibuat otomatis oleh database
  id: string

  @Column()
  title: string

  @Column()
  description: string

  @Column()
  status: TaskStatus         // disimpan sebagai string enum
}

Pindahkan TaskStatus ke file terpisah agar bisa di-import baik oleh entity maupun service:

typescript
// src/tasks/task-status.enum.ts
export enum TaskStatus {
  OPEN = 'OPEN',
  IN_PROGRESS = 'IN_PROGRESS',
  DONE = 'DONE',
}

8. Active Record vs Data Mapper

Dua pola populer untuk ORM:

Active Record — entity mewarisi class base repository, method database langsung ada di entity:

typescript
// Active Record style
const task = new Task()
task.title = 'Beli susu'
await task.save()            // entity punya method save()

const tasks = await Task.find()  // query via static method

Data Mapper — entity hanya data, repository terpisah menangani akses database:

typescript
// Data Mapper style
const task = this.tasksRepository.create({ title: 'Beli susu' })
await this.tasksRepository.save(task)

const tasks = await this.tasksRepository.find()

NestJS merekomendasikan Data Mapper karena:

  • Entity tetap bersih dari logika database
  • Lebih mudah di-mock saat testing
  • Sesuai dengan prinsip separation of concerns

9. Creating a Tasks Repository

Buat custom repository untuk mengelompokkan semua query terkait task:

typescript
// src/tasks/tasks.repository.ts
import { DataSource, Repository } from 'typeorm'
import { Injectable } from '@nestjs/common'
import { Task } from './task.entity'
import { CreateTaskDto } from './dto/create-task.dto'
import { TaskStatus } from './task-status.enum'

@Injectable()
export class TasksRepository extends Repository<Task> {
  constructor(private dataSource: DataSource) {
    super(Task, dataSource.createEntityManager())
  }

  async createTask(createTaskDto: CreateTaskDto): Promise<Task> {
    const { title, description } = createTaskDto
    const task = this.create({
      title,
      description,
      status: TaskStatus.OPEN,
    })
    await this.save(task)
    return task
  }
}

Daftarkan repository dan entity di TasksModule:

typescript
// src/tasks/tasks.module.ts
import { TypeOrmModule } from '@nestjs/typeorm'
import { Task } from './task.entity'
import { TasksRepository } from './tasks.repository'

@Module({
  imports: [TypeOrmModule.forFeature([Task])],
  controllers: [TasksController],
  providers: [TasksService, TasksRepository],
})
export class TasksModule {}

10. Refactoring for Tasks Service

Array private tasks: Task[] dihapus dari service. Service sekarang menerima repository melalui dependency injection:

typescript
// src/tasks/tasks.service.ts
@Injectable()
export class TasksService {
  constructor(private tasksRepository: TasksRepository) {}

  async getTasks(filterDto: GetTasksFilterDto): Promise<Task[]> {
    return this.tasksRepository.getTasks(filterDto)
  }

  async createTask(createTaskDto: CreateTaskDto): Promise<Task> {
    return this.tasksRepository.createTask(createTaskDto)
  }
  // ...
}

Perhatikan perubahan besar: semua method kini async dan mengembalikan Promise<T>. Ini karena operasi database adalah I/O asinkron — tidak bisa langsung mengembalikan nilai seperti operasi array sebelumnya.

11. Persistence: Getting a Task by ID

typescript
// src/tasks/tasks.service.ts
async getTaskById(id: string): Promise<Task> {
  const found = await this.tasksRepository.findOne({ where: { id } })

  if (!found) {
    throw new NotFoundException(`Task dengan ID "${id}" tidak ditemukan`)
  }

  return found
}
typescript
// src/tasks/tasks.controller.ts
@Get(':id')
async getTaskById(@Param('id') id: string): Promise<Task> {
  return this.tasksService.getTaskById(id)
}

Controller juga perlu async karena memanggil method async service.

12. Persistence: Creating a Task

Method createTask di repository membuat entity baru dengan this.create() lalu menyimpannya dengan this.save(). Pemisahan keduanya penting: create() hanya membuat object TypeScript, belum ke database. save() yang mengirim INSERT ke database:

typescript
// src/tasks/tasks.repository.ts
async createTask(createTaskDto: CreateTaskDto): Promise<Task> {
  const { title, description } = createTaskDto

  const task = this.create({   // membuat instance Task, belum ke database
    title,
    description,
    status: TaskStatus.OPEN,
  })

  await this.save(task)        // INSERT ke database, mengisi id secara otomatis
  return task
}

13. Persistence: Deleting a Task

TypeORM delete() langsung menghapus berdasarkan kondisi tanpa perlu mengambil entity lebih dulu. Ini lebih efisien karena hanya satu query ke database:

typescript
// src/tasks/tasks.service.ts
async deleteTask(id: string): Promise<void> {
  const result = await this.tasksRepository.delete(id)

  if (result.affected === 0) {
    // tidak ada row yang terhapus — berarti ID tidak ada
    throw new NotFoundException(`Task dengan ID "${id}" tidak ditemukan`)
  }
}

result.affected adalah jumlah baris yang terdampak operasi. Jika 0, berarti tidak ada task dengan ID tersebut.

14. Persistence: Update Task Status

Pola paling sederhana: ambil entity, ubah property, simpan kembali:

typescript
// src/tasks/tasks.service.ts
async updateTaskStatus(id: string, status: TaskStatus): Promise<Task> {
  const task = await this.getTaskById(id)  // akan throw 404 jika tidak ada
  task.status = status
  await this.tasksRepository.save(task)    // UPDATE ke database
  return task
}

15. Small Change Needed

Method getAllTasks() dan getTasksWithFilters() disatukan menjadi satu method getTasks() agar lebih mudah diperluas dengan Query Builder. Service dan controller masing-masing disesuaikan untuk memanggil satu method ini.

16. Persistence: Getting All Tasks

Query Builder dipakai untuk membangun query yang fleksibel. Kondisi WHERE ditambahkan hanya jika parameter tersedia:

typescript
// src/tasks/tasks.repository.ts
import { GetTasksFilterDto } from './dto/get-tasks-filter.dto'

async getTasks(filterDto: GetTasksFilterDto): Promise<Task[]> {
  const { status, search } = filterDto

  const query = this.createQueryBuilder('task')   // 'task' adalah alias tabel

  if (status) {
    query.andWhere('task.status = :status', { status })
  }

  if (search) {
    query.andWhere(
      '(LOWER(task.title) LIKE LOWER(:search) OR LOWER(task.description) LIKE LOWER(:search))',
      { search: `%${search}%` },
    )
  }

  const tasks = await query.getMany()
  return tasks
}

Parameter binding :status dan :search (bukan string interpolasi) penting untuk mencegah SQL injection. Jangan pernah memasukkan input pengguna langsung ke string query SQL.

Konsep Kunci

KonsepFungsiCatatan
EntityRepresentasi table databaseClass TypeScript dengan decorator TypeORM
RepositoryLayer akses dataTempat query dan operasi persistence
Data MapperMemisahkan entity dari data accessLebih maintainable untuk aplikasi besar
Query BuilderMembangun query kompleks secara programmaticCocok untuk filter kondisional
async/awaitMenangani operasi databaseSemua query database bersifat async
synchronizeMenyesuaikan schema otomatisPraktis di dev, berisiko di production

Command dan Konfigurasi

bash
docker run --name postgres-nest -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres
yarn add @nestjs/typeorm typeorm pg
typescript
TypeOrmModule.forRoot({
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'postgres',
  password: 'postgres',
  database: 'task_management',
  autoLoadEntities: true,
  synchronize: true,
})

Pola Entity dan Repository

typescript
@Entity()
export class Task {
  @PrimaryGeneratedColumn('uuid')
  id: string

  @Column()
  title: string

  @Column()
  description: string

  @Column()
  status: TaskStatus
}
typescript
const query = this.createQueryBuilder('task')

if (status) {
  query.andWhere('task.status = :status', { status })
}

if (search) {
  query.andWhere(
    '(LOWER(task.title) LIKE LOWER(:search) OR LOWER(task.description) LIKE LOWER(:search))',
    { search: `%${search}%` },
  )
}

Jebakan Umum

  • Lupa await saat memanggil repository method.
  • Mencampur business logic di repository dan membuat service kehilangan peran.
  • Menggunakan synchronize: true di production.
  • Membuat query string manual tanpa parameter binding.
  • Tidak mengecek result.affected setelah delete.
  • Mengira ORM membuat pemahaman SQL tidak diperlukan sama sekali.

Pertanyaan Reflektif

  1. Mengapa database operation membuat return type berubah menjadi Promise<T>?
  2. Apa keuntungan Data Mapper dibanding Active Record dalam aplikasi NestJS?
  3. Mengapa delete(id) bisa lebih efisien daripada mengambil entity lalu menghapusnya?
  4. Mengapa synchronize: true berbahaya di production?
  5. Bagaimana Query Builder membantu menggabungkan filter status dan search tanpa banyak method terpisah?