ORM 整合實戰:TypeORM、Prisma、SQLAlchemy、Django ORM 與 PostgreSQL 進階型別映射 | PostgreSQL

2026/07/23
ORM 整合實戰:TypeORM、Prisma、SQLAlchemy、Django ORM 與 PostgreSQL 進階型別映射 | PostgreSQL

ORM(Object-Relational Mapping)框架在應用程式物件模型與 PostgreSQL 關聯式資料模型之間搭建橋梁。本文完整解析 TypeORMPrismaSQLAlchemyDjango ORMDrizzle 五大框架與 PostgreSQL 的型別映射、進階查詢技巧、N+1 問題解法,以及何時該跳出 ORM 直接使用 Raw SQL

ORM 的核心抽象層次

ORM 在應用程式與資料庫之間引入多個抽象層:

應用程式層(Application Layer)
        │
        ▼
物件/類別定義(Entity / Model Class)
        │  ← ORM 核心抽象
        ▼
SQL 查詢生成器(Query Builder)
        │  ← 框架差異最大的地方
        ▼
資料庫驅動(Database Driver)
  Node.js: pg / postgres.js
  Python:  psycopg2 / asyncpg
        │
        ▼
PostgreSQL Wire Protocol → PostgreSQL Backend

抽象層帶來的能力: 型別安全(編譯期捕捉欄位錯誤)、遷移管理(Schema 變更版本化)、關係處理(自動 JOIN、Lazy/Eager Loading)。

抽象層的代價: 可能生成低效 SQL(N+1 問題)、遮蔽 PostgreSQL 特有功能(Array 操作、全文搜尋、WINDOW 函式)。

各框架 PostgreSQL 特性支援概覽

特性TypeORMPrismaSQLAlchemyDjango ORMDrizzle
JSON/JSONB部分支援完整支援完整支援完整支援完整支援
Array 型別支援需 Raw完整支援ArrayField支援
全文搜尋需 Raw需 Raw完整支援原生支援需 Raw
ENUM原生支援原生支援原生支援原生支援原生支援
RETURNING支援支援支援部分支援支援
LISTEN/NOTIFY需繞過需繞過需繞過需繞過需繞過
資料表繼承STI/CTI不支援完整支援部分支援不支援
遷移工具內建內建Alembic內建Drizzle Kit

TypeORM — TypeScript/NestJS 生態主流

連線與連線池配置

import { DataSource } from 'typeorm';

const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,

  ssl: process.env.NODE_ENV === 'production'
    ? { rejectUnauthorized: true, ca: process.env.DB_SSL_CA }
    : false,

  extra: {
    max: 20,                        // 連線池最大連線數
    min: 5,                         // 最小閒置連線數
    idleTimeoutMillis: 30000,       // 閒置逾時
    connectionTimeoutMillis: 5000,  // 建立逾時
    statement_timeout: 10000,       // 查詢逾時
    application_name: 'my-app',     // pg_stat_activity 顯示名稱
  },

  entities: [__dirname + '/**/*.entity{.ts,.js}'],
  migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
  synchronize: false,  // 生產環境必須關閉
  logging: ['query', 'error'],
});

Entity 與 PostgreSQL 型別映射

import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn, Index
} from 'typeorm';

@Entity('products')
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  // NUMERIC(金融計算,避免浮點誤差)
  @Column({ type: 'numeric', precision: 15, scale: 4 })
  price: number;

  // JSONB(結構化但彈性的資料)
  @Column({ type: 'jsonb', nullable: true })
  metadata: Record<string, unknown> | null;

  // Array(PostgreSQL 原生陣列)
  @Column({ type: 'text', array: true, default: '{}' })
  tags: string[];

  // Enum(對應 PostgreSQL ENUM)
  @Column({
    type: 'enum',
    enum: ['draft', 'published', 'archived'],
    default: 'draft',
  })
  status: 'draft' | 'published' | 'archived';

  @CreateDateColumn({ type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamptz' })
  updatedAt: Date;
}

QueryBuilder 操作 PostgreSQL 特性

@Injectable()
export class ProductRepository {
  constructor(
    @InjectRepository(Product)
    private readonly repo: Repository<Product>,
  ) {}

  // JSONB 查詢
  async findByMetadata(key: string, value: string): Promise<Product[]> {
    return this.repo.createQueryBuilder('p')
      .where("p.metadata ->> :key = :value", { key, value })
      .getMany();
  }

  // Array 操作(ANY)
  async findByTag(tag: string): Promise<Product[]> {
    return this.repo.createQueryBuilder('p')
      .where(":tag = ANY(p.tags)", { tag })
      .getMany();
  }

  // 全文搜尋(需 Raw SQL)
  async fullTextSearch(query: string): Promise<Product[]> {
    return this.repo.query(`
      SELECT * FROM products
      WHERE search_vector @@ plainto_tsquery('chinese', $1)
      ORDER BY ts_rank(search_vector, plainto_tsquery('chinese', $1)) DESC
      LIMIT 20
    `, [query]);
  }

  // UPSERT with RETURNING
  async upsertProduct(data: Partial<Product>): Promise<Product> {
    const result = await this.repo.createQueryBuilder()
      .insert()
      .into(Product)
      .values(data)
      .orUpdate(['name', 'price', 'metadata'], ['id'])
      .returning('*')
      .execute();
    return result.raw[0];
  }
}

Prisma — 現代 TypeScript ORM 的型別安全代表

Schema 定義與 PostgreSQL 特性

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["postgresqlExtensions", "fullTextSearch"]
}

datasource db {
  provider   = "postgresql"
  url        = env("DATABASE_URL")
  directUrl  = env("DIRECT_URL")  // 搭配 PgBouncer 時必要
  extensions = [pgcrypto, pg_trgm, unaccent]
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

model Product {
  id        String      @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  name      String      @db.VarChar(255)
  price     Decimal     @db.Decimal(15, 4)
  metadata  Json?
  status    OrderStatus @default(PENDING)
  createdAt DateTime    @default(now()) @db.Timestamptz(3)
  updatedAt DateTime    @updatedAt @db.Timestamptz(3)

  @@index([metadata], type: Gin)
  @@map("products")
}

Prisma 查詢 PostgreSQL 特性

import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();

// JSONB 查詢(Prisma JSON 過濾語法)
async function queryByMetadata() {
  return prisma.product.findMany({
    where: {
      metadata: {
        path: ['category'],
        equals: 'electronics',
      },
    },
  });
}

// 全文搜尋(需啟用 previewFeature)
async function fullTextSearch(query: string) {
  return prisma.article.findMany({
    where: {
      OR: [
        { title: { search: query } },
        { content: { search: query } },
      ],
    },
    orderBy: {
      _relevance: {
        fields: ['title', 'content'],
        search: query,
        sort: 'desc',
      },
    },
  });
}

// $queryRaw — 處理 Prisma 不支援的 PG 特性
async function rawArrayQuery(tags: string[]) {
  return prisma.$queryRaw<Product[]>`
    SELECT * FROM products
    WHERE tags && ${tags}::text[]
    ORDER BY created_at DESC
  `;
}

// 交易(Serializable 隔離等級)
async function transfer(fromId: string, toId: string, amount: number) {
  return prisma.$transaction(async (tx) => {
    const from = await tx.account.update({
      where: { id: fromId },
      data: { balance: { decrement: amount } },
    });
    if (from.balance < 0) throw new Error('餘額不足');
    return tx.account.update({
      where: { id: toId },
      data: { balance: { increment: amount } },
    });
  }, {
    isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
    timeout: 10000,
  });
}

Prisma 與 PgBouncer 整合

# .env
# 透過 PgBouncer(關閉 prepared statements)
DATABASE_URL="postgresql://user:pass@pgbouncer:6432/db?pgbouncer=true"
# 直連 PostgreSQL(用於 Migrate)
DIRECT_URL="postgresql://user:pass@postgres:5432/db"

SQLAlchemy — Python 生態最成熟的 ORM

Engine 與連線池配置

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from sqlalchemy.pool import QueuePool

engine = create_engine(
    "postgresql+psycopg2://user:pass@localhost:5432/mydb",

    poolclass=QueuePool,
    pool_size=10,
    max_overflow=20,
    pool_timeout=30,
    pool_recycle=1800,
    pool_pre_ping=True,

    connect_args={
        "application_name": "my-python-app",
        "options": "-c statement_timeout=10000",
        "sslmode": "require",
    },
)

SessionLocal = sessionmaker(
    bind=engine, autocommit=False, autoflush=False,
    expire_on_commit=False,
)

Model 定義與 PG Dialect 型別

from sqlalchemy import Column, String, Numeric, DateTime, Index, Enum as SAEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY, TSVECTOR
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func
import uuid
from datetime import datetime
from typing import Optional

class Base(DeclarativeBase):
    pass

class Product(Base):
    __tablename__ = "products"

    # UUID 主鍵
    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True,
        server_default=func.gen_random_uuid()
    )
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    price: Mapped[float] = mapped_column(Numeric(15, 4), nullable=False)

    # JSONB
    metadata_: Mapped[Optional[dict]] = mapped_column('metadata', JSONB, nullable=True)

    # ARRAY
    tags: Mapped[Optional[list]] = mapped_column(ARRAY(String), nullable=True)

    # PostgreSQL ENUM
    status: Mapped[str] = mapped_column(
        SAEnum('draft', 'published', 'archived', name='product_status'),
        default='draft'
    )

    # TSVECTOR(全文搜尋向量)
    search_vector: Mapped[Optional[str]] = mapped_column(TSVECTOR, nullable=True)

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )

    __table_args__ = (
        Index('ix_products_metadata_gin', metadata_, postgresql_using='gin'),
        Index('ix_products_search_gin', search_vector, postgresql_using='gin'),
        Index('ix_products_tags_gin', tags, postgresql_using='gin'),
    )

SQLAlchemy 操作 PostgreSQL 特性

from sqlalchemy import select, func, text
from sqlalchemy.dialects.postgresql import insert

class ProductService:
    def __init__(self, db):
        self.db = db

    # JSONB 查詢
    def find_by_metadata(self, key: str, value: str):
        return self.db.execute(
            select(Product).where(
                Product.metadata_[key].astext == value
            )
        ).scalars().all()

    # JSONB 包含查詢(@>)
    def find_containing(self, criteria: dict):
        return self.db.execute(
            select(Product).where(
                Product.metadata_.contains(criteria)
            )
        ).scalars().all()

    # Array 操作
    def find_by_tag(self, tag: str):
        return self.db.execute(
            select(Product).where(Product.tags.contains([tag]))
        ).scalars().all()

    # 全文搜尋
    def full_text_search(self, query: str):
        ts_query = func.plainto_tsquery('chinese', query)
        return self.db.execute(
            select(Product)
            .where(Product.search_vector.op('@@')(ts_query))
            .order_by(func.ts_rank(Product.search_vector, ts_query).desc())
        ).scalars().all()

    # UPSERT(INSERT ... ON CONFLICT)
    def upsert(self, data: dict):
        stmt = insert(Product).values(**data)
        stmt = stmt.on_conflict_do_update(
            index_elements=['id'],
            set_={'name': stmt.excluded.name, 'price': stmt.excluded.price}
        ).returning(Product)
        return self.db.execute(stmt).fetchone()

Django ORM — PostgreSQL 專屬模組

Django 提供 django.contrib.postgres 專屬模組,對 PostgreSQL 特性支援最為完整:

PostgreSQL 專屬 Field

from django.db import models
from django.contrib.postgres.fields import ArrayField, HStoreField
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex, BrinIndex

class Product(models.Model):
    name = models.CharField(max_length=255)
    price = models.DecimalField(max_digits=15, decimal_places=4)

    # JSONField(自動使用 JSONB on PostgreSQL)
    metadata = models.JSONField(null=True, blank=True)

    # ArrayField(PostgreSQL 專屬)
    tags = ArrayField(
        models.CharField(max_length=50),
        blank=True, default=list,
    )

    # HStoreField(需啟用 hstore 擴展)
    properties = HStoreField(null=True, blank=True)

    # SearchVectorField(全文搜尋向量)
    search_vector = SearchVectorField(null=True, editable=False)

    status = models.CharField(max_length=20, default='draft')
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            GinIndex(fields=['tags'], name='ix_products_tags_gin'),
            GinIndex(fields=['metadata'], name='ix_products_metadata_gin'),
            GinIndex(fields=['search_vector'], name='ix_products_search_gin'),
            BrinIndex(fields=['created_at'], name='ix_products_created_brin'),
        ]

Django 全文搜尋(原生支援)

from django.contrib.postgres.search import (
    SearchQuery, SearchRank, SearchVector, TrigramSimilarity,
)
from django.db.models import F

class ArticleSearchService:
    # 搜尋排名
    def ranked_search(self, query: str, limit: int = 20):
        search_query = SearchQuery(query, config='chinese')
        search_rank = SearchRank(F('search_vector'), search_query)
        return (
            Article.objects
            .filter(search_vector=search_query)
            .annotate(rank=search_rank)
            .order_by('-rank')[:limit]
        )

    # 多欄位即時向量搜尋
    def multi_field_search(self, query: str):
        search_vector = (
            SearchVector('title', weight='A', config='english') +
            SearchVector('content', weight='B', config='english')
        )
        search_query = SearchQuery(query, config='english')
        return (
            Article.objects
            .annotate(search=search_vector, rank=SearchRank(search_vector, search_query))
            .filter(search=search_query)
            .order_by('-rank')
        )

    # Trigram 相似度搜尋(需 pg_trgm 擴展)
    def trigram_search(self, query: str, threshold: float = 0.3):
        return (
            Article.objects
            .annotate(similarity=TrigramSimilarity('title', query))
            .filter(similarity__gt=threshold)
            .order_by('-similarity')
        )

ArrayField 與 JSONField 查詢

# ArrayField 查詢
Product.objects.filter(tags__contains=['python'])       # 包含
Product.objects.filter(tags__overlap=['python', 'js'])   # 任一重疊
Product.objects.filter(tags__0='python')                 # 索引存取

# JSONField 查詢
Product.objects.filter(metadata__category='electronics')         # 鍵值
Product.objects.filter(metadata__specs__ram__gte=16)              # 巢狀路徑
Product.objects.filter(metadata__tags__contains=['urgent'])       # JSON 陣列包含

Drizzle ORM — 輕量 SQL 建構器

import { pgTable, pgEnum, varchar, numeric, jsonb, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql, eq } from 'drizzle-orm';

export const productStatusEnum = pgEnum('product_status', [
  'draft', 'published', 'archived',
]);

export const products = pgTable('products', {
  id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
  name: varchar('name', { length: 255 }).notNull(),
  price: numeric('price', { precision: 15, scale: 4 }).notNull(),
  metadata: jsonb('metadata'),
  status: productStatusEnum('status').default('draft').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
  metadataGin: index('ix_products_metadata_gin').using('gin', table.metadata),
}));

// 查詢
const result = await db.select().from(products).where(eq(products.status, 'published'));

// JSONB(需 sql helper)
const byMeta = await db.select().from(products)
  .where(sql`${products.metadata} ->> 'category' = ${'electronics'}`);

// UPSERT
await db.insert(products).values(data)
  .onConflictDoUpdate({
    target: products.id,
    set: { name: sql`EXCLUDED.name`, price: sql`EXCLUDED.price` },
  });

N+1 問題與解法

N+1 問題是 ORM 最常見的效能陷阱——查詢 N 筆主記錄後,對每筆記錄各發送 1 次查詢取得關聯資料。

問題識別

# Django N+1(每次迴圈觸發額外查詢)
orders = Order.objects.filter(status='pending')   # 1 次查詢
for order in orders:
    print(order.customer.name)                     # N 次查詢

應用程式端解法(Eager Loading)

# Django:select_related(JOIN,適合 FK/OneToOne)
orders = Order.objects.filter(status='pending').select_related('customer')

# Django:prefetch_related(分開查詢 + 合併,適合 M2M/反向 FK)
orders = Order.objects.filter(status='pending').prefetch_related('items', 'items__product')
// TypeORM:relations 選項
const orders = await orderRepo.find({
  where: { status: 'pending' },
  relations: ['customer', 'items', 'items.product'],
});
# SQLAlchemy:selectinload / joinedload
from sqlalchemy.orm import selectinload, joinedload

orders = session.execute(
    select(Order)
    .options(
        joinedload(Order.customer),
        selectinload(Order.items).selectinload(Item.product),
    )
    .where(Order.status == 'pending')
).scalars().unique().all()

PostgreSQL 端解法(JSON 聚合)

將 N+1 合併為單一查詢,充分利用 PostgreSQL 的 JSON 聚合能力:

SELECT
    o.id, o.status,
    row_to_json(c.*) AS customer,
    COALESCE(
      json_agg(
        json_build_object('id', i.id, 'product', p.name)
        ORDER BY i.id
      ) FILTER (WHERE i.id IS NOT NULL),
      '[]'
    ) AS items
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
LEFT JOIN order_items i ON o.id = i.order_id
LEFT JOIN products p ON i.product_id = p.id
WHERE o.status = 'pending'
GROUP BY o.id, c.id;

連線池配置黃金法則

pool_size ≤ (max_connections - 預留連線) / 應用實例數

範例:max_connections=200, 預留=20, 4 個 Pod
→ 每實例最多 (200-20)/4 = 45 → 設定 pool_size=40
ORM設定位置關鍵參數
TypeORMDataSource.extramax, min, idleTimeoutMillis
Prisma連線字串 Query Stringconnection_limit, pool_timeout
SQLAlchemycreate_engine()pool_size, max_overflow, pool_recycle
Djangosettings.DATABASESCONN_MAX_AGE, CONN_HEALTH_CHECKS
DrizzlePool 設定max, min, idleTimeoutMillis

何時跳出 ORM 使用 Raw SQL?

場景原因
WINDOW 函式(RANK、LAG、LEAD)多數 ORM 支援不完整
複雜 JSONB 操作(jsonb_set、jsonb_each)ORM 語法無法表達完整路徑操作
PostgreSQL 全文搜尋Prisma/TypeORM/Drizzle 支援有限
EXCLUDE 約束(防止範圍重疊)ORM Migration 無法直接生成
遞迴 CTE(樹狀結構遍歷)ORM 幾乎都不支援
COPY 命令(大量資料匯入)需直接呼叫 pg 驅動的 COPY API
LISTEN/NOTIFY(實時通知)需在 ORM 外管理持久連線
分區表 PARTITION 操作ORM Migration 通常無法處理

總結

選擇 ORM 框架與 PostgreSQL 整合的關鍵要點:

  1. TypeORM 適合 NestJS 生態,Decorator 語法直覺但進階 PG 特性需 Raw SQL
  2. Prisma 型別安全最強,Schema-first 開發體驗佳,但 Array/全文搜尋需 Raw
  3. SQLAlchemy 對 PostgreSQL 特性支援最完整,Core + ORM 雙層 API 靈活度最高
  4. Django ORM 內建 django.contrib.postgres 專屬模組,全文搜尋原生支援最佳
  5. Drizzle 輕量且接近 SQL 語法,適合偏好「SQL-first」的 TypeScript 開發者

無論選擇哪個框架,都要掌握:正確配置連線池、使用 Eager Loading 避免 N+1、善用 EXPLAIN ANALYZE 檢查生成的 SQL、在 ORM 無法表達時果斷使用 Raw SQL。

下一篇,我們將探討 雲端託管 PostgreSQL——從 AWS RDS、Google Cloud SQL 到 Supabase,掌握各大雲平台的 PostgreSQL 服務差異與最佳配置。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →