iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0

前言

前兩天整理完 PostgreSQL,今天再繼續整理更進階的 TypeORM,簡而言之,PostgreSQL 是存資料的地方,TypeORM 是幫你「用 TypeScript 操作 PostgreSQL」的工具。

什麼是 TypeORM

TypeORM 是 TypeScript 和 JavaScript 的 ORM(Object-Relational Mapping)框架,讓你可以用物件導向的方式操作資料庫。

為什麼要用 ORM?

不用 ORM(原生 SQL)

const result = await client.query(
  'SELECT * FROM users WHERE email = $1',
  ['alice@example.com']
);
const user = result.rows[0];

使用 TypeORM

const user = await userRepository.findOne({
  where: { email: 'alice@example.com' }
});

ORM 的優缺點

優點

  • 類型安全(TypeScript 支援)
  • 減少 SQL 注入風險
  • 程式碼更易維護
  • 支援多種資料庫(PostgreSQL, MySQL, SQLite...)
  • 自動化 Migration

缺點

  • 效能略差(但通常可接受)
  • 複雜查詢較難寫
  • 需要學習成本

安裝與設定

安裝套件

npm install typeorm reflect-metadata pg
npm install --save-dev @types/node

套件說明

  • typeorm: ORM 核心
  • reflect-metadata: 裝飾器需要
  • pg: PostgreSQL 驅動

專案結構

src/
├── config/
│   └── database.ts          # 資料庫設定
├── entities/
│   ├── User.ts              # User Entity
│   ├── Post.ts              # Post Entity
│   └── Tag.ts               # Tag Entity
├── migrations/              # 資料庫遷移檔案
└── index.ts                 # 主程式

資料庫設定

// src/config/database.ts
import { DataSource } from 'typeorm';
import { User } from '../entities/User';
import { Post } from '../entities/Post';
import { Tag } from '../entities/Tag';

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USER || 'myuser',
  password: process.env.DB_PASSWORD || 'mysecretpassword',
  database: process.env.DB_NAME || 'mydb',
  
  // 開發環境自動同步(生產環境請設為 false)
  synchronize: process.env.NODE_ENV === 'development',
  
  // 顯示 SQL 語句(除錯用)
  logging: process.env.NODE_ENV === 'development',
  
  // Entity 檔案位置
  entities: [User, Post, Tag],
  
  // Migration 檔案位置
  migrations: ['src/migrations/*.ts'],
});

環境變數

# .env
DB_HOST=localhost
DB_PORT=5432
DB_USER=myuser
DB_PASSWORD=mysecretpassword
DB_NAME=mydb
NODE_ENV=development

Entity 定義

Entity 就是資料表的物件表示。

基本 Entity

// src/entities/User.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity('users')  // 對應資料表名稱
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @Column({ nullable: true })
  age: number;

  @Column({ default: true })
  isActive: boolean;

  @CreateDateColumn()  // 自動設定建立時間
  createdAt: Date;

  @UpdateDateColumn()  // 自動更新時間
  updatedAt: Date;
}

常用裝飾器

@Column()                              // 一般欄位
@Column({ nullable: true })            // 可為 null
@Column({ unique: true })              // 唯一值
@Column({ default: 'active' })         // 預設值
@Column({ length: 100 })               // 字串長度
@Column('text')                        // 指定型別
@Column({ type: 'decimal', precision: 10, scale: 2 })  // 小數

@PrimaryGeneratedColumn()              // 自動遞增主鍵
@PrimaryGeneratedColumn('uuid')        // UUID 主鍵

@CreateDateColumn()                    // 建立時間(自動)
@UpdateDateColumn()                    // 更新時間(自動)

主程式初始化

// src/index.ts
import 'reflect-metadata';
import { AppDataSource } from './config/database';

const main = async () => {
  try {
    // 初始化資料庫連線
    await AppDataSource.initialize();
    console.log('資料庫連線成功');

    // 你的程式邏輯...

  } catch (error) {
    console.error('資料庫連線失敗:', error);
  }
};

main();

基本 CRUD 操作

Create(新增)

import { AppDataSource } from './config/database';
import { User } from './entities/User';

const userRepository = AppDataSource.getRepository(User);

// 方式 1:create + save
const user = userRepository.create({
  email: 'alice@example.com',
  name: 'Alice',
  age: 25,
});
await userRepository.save(user);

// 方式 2:直接 save
await userRepository.save({
  email: 'bob@example.com',
  name: 'Bob',
  age: 30,
});

// 方式 3:insert(效能較好,但不會觸發事件)
await userRepository.insert({
  email: 'charlie@example.com',
  name: 'Charlie',
  age: 28,
});

// 批次新增
await userRepository.save([
  { email: 'user1@example.com', name: 'User1' },
  { email: 'user2@example.com', name: 'User2' },
]);

Read(查詢)

// 查詢所有
const users = await userRepository.find();

// 查詢單筆(找不到回傳 null)
const user = await userRepository.findOne({
  where: { email: 'alice@example.com' }
});

// 查詢單筆(找不到拋出錯誤)
const user = await userRepository.findOneOrFail({
  where: { id: '123' }
});

// 條件查詢
const users = await userRepository.find({
  where: { isActive: true }
});

// 多重條件(AND)
const users = await userRepository.find({
  where: { 
    isActive: true,
    age: 25 
  }
});

// 多重條件(OR)
import { Or } from 'typeorm';
const users = await userRepository.find({
  where: [
    { age: 25 },
    { age: 30 }
  ]
});

// 大於、小於
import { MoreThan, LessThan } from 'typeorm';
const users = await userRepository.find({
  where: { age: MoreThan(25) }
});

// 模糊搜尋
import { Like } from 'typeorm';
const users = await userRepository.find({
  where: { name: Like('%Alice%') }
});

// 排序
const users = await userRepository.find({
  order: { createdAt: 'DESC' }
});

// 限制筆數
const users = await userRepository.find({
  take: 10,    // LIMIT 10
  skip: 20,    // OFFSET 20
});

// 選擇欄位
const users = await userRepository.find({
  select: ['id', 'name', 'email']
});

// 計數
const count = await userRepository.count({
  where: { isActive: true }
});

Update(更新)

// 方式 1:先查詢再更新
const user = await userRepository.findOne({
  where: { email: 'alice@example.com' }
});
if (user) {
  user.name = 'Alice Smith';
  user.age = 26;
  await userRepository.save(user);
}

// 方式 2:直接更新(效能較好)
await userRepository.update(
  { email: 'alice@example.com' },  // 條件
  { name: 'Alice Smith', age: 26 } // 更新內容
);

// 方式 3:使用 Query Builder
await userRepository
  .createQueryBuilder()
  .update(User)
  .set({ age: 27 })
  .where('email = :email', { email: 'alice@example.com' })
  .execute();

// 批次更新
await userRepository.update(
  { isActive: false },
  { isActive: true }
);

Delete(刪除)

// 方式 1:先查詢再刪除
const user = await userRepository.findOne({
  where: { email: 'alice@example.com' }
});
if (user) {
  await userRepository.remove(user);
}

// 方式 2:直接刪除(效能較好)
await userRepository.delete({
  email: 'alice@example.com'
});

// 方式 3:軟刪除(只標記,不真的刪除)
// 先在 Entity 加上 @DeleteDateColumn()
await userRepository.softDelete({
  email: 'alice@example.com'
});

// 恢復軟刪除的資料
await userRepository.restore({
  email: 'alice@example.com'
});

// 批次刪除
await userRepository.delete({
  isActive: false
});

關聯關係

一對多(One-to-Many)

一個使用者有多篇文章:

// src/entities/User.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Post } from './Post';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  // 一個 user 有多個 posts
  @OneToMany(() => Post, (post) => post.user)
  posts: Post[];
}
// src/entities/Post.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
import { User } from './User';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  title: string;

  @Column('text')
  content: string;

  // 多個 posts 屬於一個 user
  @ManyToOne(() => User, (user) => user.posts, {
    onDelete: 'CASCADE'  // 刪除 user 時連帶刪除 posts
  })
  user: User;

  @Column()
  userId: string;  // 外鍵欄位
}

使用範例

// 新增文章
const user = await userRepository.findOne({
  where: { email: 'alice@example.com' }
});

const post = postRepository.create({
  title: '我的第一篇文章',
  content: '內容...',
  user: user,  // 或 userId: user.id
});
await postRepository.save(post);

// 查詢使用者及其所有文章
const user = await userRepository.findOne({
  where: { email: 'alice@example.com' },
  relations: ['posts']  // 載入關聯資料
});

console.log(user.posts);  // 使用者的所有文章

// 查詢文章及其作者
const post = await postRepository.findOne({
  where: { id: '123' },
  relations: ['user']
});

console.log(post.user.name);  // 作者名稱

多對多(Many-to-Many)

一篇文章有多個標籤,一個標籤屬於多篇文章:

// src/entities/Post.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Tag } from './Tag';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  title: string;

  @Column('text')
  content: string;

  // 多對多關係
  @ManyToMany(() => Tag, (tag) => tag.posts)
  @JoinTable({
    name: 'post_tags',  // 關聯表名稱
    joinColumn: { name: 'post_id' },
    inverseJoinColumn: { name: 'tag_id' }
  })
  tags: Tag[];
}
// src/entities/Tag.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { Post } from './Post';

@Entity('tags')
export class Tag {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  name: string;

  @ManyToMany(() => Post, (post) => post.tags)
  posts: Post[];
}

使用範例

// 新增標籤
const tag1 = tagRepository.create({ name: 'PostgreSQL' });
const tag2 = tagRepository.create({ name: 'TypeScript' });
await tagRepository.save([tag1, tag2]);

// 為文章加上標籤
const post = await postRepository.findOne({
  where: { id: '123' },
  relations: ['tags']
});

post.tags = [tag1, tag2];
await postRepository.save(post);

// 查詢文章及其標籤
const posts = await postRepository.find({
  relations: ['tags']
});

posts.forEach(post => {
  console.log(post.title, post.tags.map(t => t.name));
});

// 查詢特定標籤的所有文章
const tag = await tagRepository.findOne({
  where: { name: 'PostgreSQL' },
  relations: ['posts']
});

console.log(tag.posts);

Query Builder(進階查詢)

當關聯查詢較複雜時,使用 Query Builder:

// 複雜的 JOIN 查詢
const posts = await postRepository
  .createQueryBuilder('post')
  .leftJoinAndSelect('post.user', 'user')
  .leftJoinAndSelect('post.tags', 'tag')
  .where('user.email = :email', { email: 'alice@example.com' })
  .andWhere('post.createdAt > :date', { date: new Date('2024-01-01') })
  .orderBy('post.createdAt', 'DESC')
  .take(10)
  .getMany();

// 聚合查詢
const result = await postRepository
  .createQueryBuilder('post')
  .select('user.name', 'author')
  .addSelect('COUNT(post.id)', 'postCount')
  .leftJoin('post.user', 'user')
  .groupBy('user.name')
  .getRawMany();

// 子查詢
const posts = await postRepository
  .createQueryBuilder('post')
  .where((qb) => {
    const subQuery = qb
      .subQuery()
      .select('user.id')
      .from(User, 'user')
      .where('user.isActive = :active', { active: true })
      .getQuery();
    return 'post.userId IN ' + subQuery;
  })
  .getMany();

Migration(資料庫遷移)

Migration 用來管理資料庫結構變更,類似版本控制。

設定 Migration

// src/config/database.ts
export const AppDataSource = new DataSource({
  // ...其他設定
  
  // 生產環境必須關閉 synchronize
  synchronize: false,
  
  // Migration 設定
  migrations: ['src/migrations/*.ts'],
  migrationsTableName: 'migrations',
});

建立 Migration

# 根據 Entity 變更自動產生 Migration
npx typeorm migration:generate src/migrations/CreateUsers -d src/config/database.ts

# 手動建立空白 Migration
npx typeorm migration:create src/migrations/AddEmailIndex

Migration 檔案範例

// src/migrations/1234567890-CreateUsers.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUsers1234567890 implements MigrationInterface {
  // 執行遷移
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE "users" (
        "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
        "email" varchar(255) UNIQUE NOT NULL,
        "name" varchar(100) NOT NULL,
        "created_at" TIMESTAMP DEFAULT now()
      )
    `);

    // 建立索引
    await queryRunner.query(`
      CREATE INDEX "idx_users_email" ON "users" ("email")
    `);
  }

  // 回滾遷移
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "idx_users_email"`);
    await queryRunner.query(`DROP TABLE "users"`);
  }
}

執行 Migration

# 執行所有待執行的 Migration
npx typeorm migration:run -d src/config/database.ts

# 回滾最後一個 Migration
npx typeorm migration:revert -d src/config/database.ts

# 查看 Migration 狀態
npx typeorm migration:show -d src/config/database.ts

Migration 最佳實踐

// 好的做法:小步前進
// Migration 1: 新增欄位
public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`ALTER TABLE "users" ADD "age" integer`);
}

// Migration 2: 設定預設值
public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`UPDATE "users" SET "age" = 0 WHERE "age" IS NULL`);
}

// Migration 3: 設定 NOT NULL
public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "age" SET NOT NULL`);
}

// 不好的做法:一次做太多事
public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`
    ALTER TABLE "users" 
    ADD "age" integer NOT NULL DEFAULT 0
  `);
}

常見問題

1. 循環依賴錯誤

// 錯誤:直接 import 會造成循環依賴
import { User } from './User';
@ManyToOne(User, (user) => user.posts)
user: User;

// 正確:使用箭頭函式延遲載入
import { User } from './User';
@ManyToOne(() => User, (user) => user.posts)
user: User;

2. 查詢效能問題

// N+1 查詢問題
const posts = await postRepository.find();
for (const post of posts) {
  // 若 relation 沒被 preload,這裡對每個 post 觸發額外查詢
  console.log(post.user.name);
}

// 使用 relations 一次載入
const posts = await postRepository.find({
  relations: ['user']
});
for (const post of posts) {
  console.log(post.user.name);
}

3. 更新沒有生效

// 錯誤:直接修改不會儲存
const user = await userRepository.findOne({ where: { id: '123' } });
user.name = 'New Name';  // 只改變記憶體物件
// 沒呼叫 save → DB 不會更新

// 正確:要呼叫 save
const user = await userRepository.findOne({ where: { id: '123' } });
if (user) {
  user.name = 'New Name';
  await userRepository.save(user); // 把變更寫回 DB
}

上一篇
Day27 - possgreSQL(2)
下一篇
Day29 - 圖片上傳
系列文
欸欸!! 這是我的學習筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言