PostgreSQL 慣例
如何套用
有兩種模式。在開始撰寫任何內容之前,請先決定使用哪一種。
模式 A — 由你選擇名稱。 使用者要求設計結構、實體或遷移,但未指定名稱。請直接套用下方的慣例。
模式 B — 名稱已存在或已被指定。 使用者已指定名稱,或者現有程式碼、遷移或資料庫中的名稱與這些慣例不符。切勿擅自重新命名。請回報並詢問。
在進行任何變更之前,請使用使用者的語言,以下列格式回報:
命名:
必須修正
- `"UserProfile"`(資料表)→ `user_profiles` — 加上引號的 PascalCase 名稱需要在每次查詢中永遠加上引號
- `created` timestamp DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') → `created_at timestamptz DEFAULT now()` — 當工作階段的 TimeZone 不是 UTC 時,值會發生偏移
建議
- `users.user_email` → `users.email` — 資料表名稱本身就已是上下文
然後問一個問題:全部套用、只套用「必須修正」,或保持原樣。等待回答。
回報規則:
- 必須修正 = 會導致錯誤、破壞查詢或工具:加上引號的大小寫混合識別碼、保留字、長度超過 63 位元組的名稱、
timestamp(無時區)、錯誤的時間預設值、json/float/money(用於需要jsonb/numeric的資料)、缺少 FK 索引、在交易外部使用鎖。 - 建議 = 樣式與一致性。
- 僅回報目前任務所觸及的物件。如果你在意發現其它偏差,請加一行:「此外還有 N 個類似的偏差在 <位置> — 需要列出嗎?」
- 已套用的遷移或線上資料庫中的物件:重新命名是一項破壞性變更。僅提出建議;如果獲得批准,則以新遷移實作。切勿編輯已套用的遷移。
- 如果程式碼庫一致使用不同的慣例,則以該慣例為準。說明一次差異,然後遵循專案慣例,無需每次出現都標記。
- ORM 命名慣例(例如 EFCore.NamingConventions)產生的名稱,直接接受。
- 一旦使用者確認了不符合慣例的名稱,即使用該名稱,並且在同一工作階段中不再提出。
一般規則
snake_case,小寫,永不使用引號括住識別碼。- 每個識別碼最多 63 位元組;較長的名稱會被靜默截斷。請以有意義的方式縮短,而非直接刪減。
- 資料表名稱使用複數:
users、orders、order_items。 - 欄位名稱使用單數,不加資料表前綴:
users.email,而非users.user_email。 - 使用完整單字,但常見縮寫除外:
id、url、ip、sku。 - 切勿使用保留字或模糊詞彙作為名稱:
user、order、group、table、limit、offset、desc、end、date、time、timestamp。 - 不加型別或物件前綴:不使用
tbl_users、str_name。 - 布林值使用正面表述:
is_deleted,而非is_not_deleted。
標準欄位
| 用途 | 名稱 | 型別 |
|---|---|---|
| 主鍵 | id | bigint generated always as identity 或 uuid v7 |
| 建立 / 更新 | created_at、updated_at | timestamptz not null default now() |
| 軟刪除 | deleted_at | timestamptz null |
| 稽核 | created_by、updated_by | FK 到 users.id |
| 標誌 | is_active、is_verified、has_children、can_edit | boolean not null default false |
| 狀態 | status | text + 檢查、列舉或查詢資料表 |
| 僅日期 | birth_date、due_on | date |
| 有效性 | expires_at、valid_from、valid_to | timestamptz |
| 金額 | amount、unit_price、total_amount + currency_code | numeric(19,4) + char(3),或 amount_minor bigint |
| 計數器 | items_count、retry_count | integer / bigint |
| 排序 | sort_order | integer |
| 業務鍵 | code、slug、external_id | text + 唯一 |
| 租戶 | tenant_id | FK |
| 並行 | version 或系統 xmin | integer / xid |
| 自由格式資料 | metadata、payload、settings | jsonb |
後綴有其含義:_at 表示時間點,_on/_date 表示日曆日期,_id 表示參考,_count 表示計數器,_url、_path、_hash。
鍵與限制
| 物件 | 模式 | 範例 |
|---|---|---|
| 主鍵 | pk_<資料表> | pk_orders |
| 外鍵 | fk_<資料表>_<參考資料表>_<欄位> | fk_orders_customers_customer_id |
| 唯一限制 | uq_<資料表>_<欄位> | uq_users_email |
| 替代鍵 (EF Core) | ak_<資料表>_<欄位> | ak_products_sku |
| 檢查 | ck_<資料表>_<規則> | ck_orders_amount_positive |
| 排除 | ex_<資料表>_<欄位> | ex_bookings_room_id_period |
| 索引 | ix_<資料表>_<欄位> | ix_orders_customer_id_created_at |
| 唯一索引 | ux_<資料表>_<欄位> | ux_users_email |
| 部分索引 | ix_<資料表>_<欄位>_<條件> | ix_users_email_not_deleted |
| 依方法索引 | ix_<資料表>_<欄位>_<方法> | ix_products_tags_gin |
| 觸發器 | trg_<資料表>_<動作> | trg_orders_set_updated_at |
| 觸發器函數 | 動詞 + 物件 | set_updated_at() |
| 序列 | PostgreSQL 預設 | orders_id_seq |
- FK 欄位:
<參考資料表,單數>_id,或當角色重要時使用角色名稱:author_id、sender_id、recipient_id、parent_id。 - FK 模式包含欄位,因此同一個資料表的多個 FK 會保持唯一:
fk_messages_users_sender_id、fk_messages_users_recipient_id。 - PostgreSQL 不會為 FK 欄位自動建立索引。每個 FK 都需要一個索引,除非複合索引已以該欄位開頭。EF Core 會依慣例建立這些索引;Drizzle、Prisma、TypeORM、Knex 和 sqlc 則不會。
關聯
| 種類 | 形狀 | 範例 |
|---|---|---|
| 一對多 | FK 在子表格上 | order_items.order_id → orders.id |
| 一對一 | FK + 唯一,或 PK = FK | user_profiles.user_id |
| 多對多 | 中介 <a>_<b>,複合 PK | user_roles (user_id, role_id),pk_user_roles |
| 帶屬性的多對多 | 有意義的實體名稱 | memberships、enrollments |
| 自我參考 | parent_id 或角色 | categories.parent_id、employees.manager_id |
多型配對(commentable_type + commentable_id)無法建立 FK。建議使用多個可為空的 FK 欄位,並搭配檢查確保恰好設定一個,或使用獨立資料表。
結構、檢視、函數、型別
| 物件 | 模式 | 範例 |
|---|---|---|
| 結構 | 領域或模組 | auth、billing、catalog、audit |
| 檢視 | v_<名稱> | v_active_users |
| 具體化檢視 | mv_<名稱> | mv_daily_sales |
| 函數 / 程序 | 動詞 + 物件 | get_user_balance、calculate_order_total |
| 列舉型別 | <實體>_<屬性> | order_status |
| 列舉值 | 小寫 snake_case | pending、paid、refunded |
| 資料庫角色 | <應用程式>_<存取> | shop_app_rw、shop_app_ro、shop_migrator |
列舉值無法在不重新建立型別的情況下刪除。對於會變更的集合,建議使用 text + 檢查或查詢資料表。
型別與預設值
- 使用
timestamptz表示時間點,預設值為now()。在timestamptz欄位上使用CURRENT_TIMESTAMP AT TIME ZONE 'UTC'是一個錯誤。 - 使用
text替代varchar(n),除非長度是業務規則;若需限制長度,建議使用check (char_length(col) <= n)。 - 使用
jsonb,絕對不要用json。 - 使用
numeric表示金額;絕對不要用real、double precision或money。 - 新的 identity 欄位使用
generated always as identity,而非serial/bigserial。 - UUID 主鍵使用 v7:PostgreSQL 18 的
uuidv7()、.NET 9 的Guid.CreateVersion7()、Node 的uuid套件v7()、Go 的github.com/google/uuiduuid.NewV7()。隨機的 v4 鍵會破壞 B-tree 索引。 gen_random_uuid()自 PostgreSQL 13 起即內建;不需要uuid-ossp和pgcrypto。- 使用
citext或lower(email)唯一索引來實現不區分大小寫的唯一性。
並行與查詢模式
- 樂觀並行,EF Core + Npgsql:使用
uint屬性並設定IsRowVersion()對應到xmin。 - 樂觀並行,Node 和 Go:使用
version integer not null default 0,透過where id = $1 and version = $2和version = version + 1更新;受影響行數為零表示發生衝突。在 sqlc 中使用:execrows取得受影響的行數。 select ... for update和for update skip locked僅能在明確的交易內部使用;在自動提交模式下,鎖會立即釋放。- 建議鎖:在交易內使用
pg_try_advisory_xact_lock。工作階段層級的pg_advisory_lock僅能在專用連線上使用,並保持開啟直到解鎖;使用連線池時,鎖定和解鎖可能落在不同的連線上。 - 大型或無限列表使用鍵集分頁:
where (created_at, id) < ($1, $2) order by created_at desc, id desc limit $3。offset僅適用於小型有限的列表。 - 未來資料表的權限需要
alter default privileges,而不僅僅是grant ... on all tables。
各 ORM 的 snake_case 設定
| 技術棧 | 設定 |
|---|---|
| EF Core + Npgsql | EFCore.NamingConventions,UseSnakeCaseNamingConvention() |
| Drizzle | casing: 'snake_case' |
| Prisma | 欄位使用 @map,模型使用 @@map |
| TypeORM | 來自 typeorm-naming-strategies 的 SnakeNamingStrategy |
| Knex / Objection | knexSnakeCaseMappers() |
| Go + sqlc | 無;Go 名稱會自動從 snake_case 產生 |
如果專案使用未設定 snake_case 的 ORM,請在首次遷移前將其回報為「必須修正」;在遷移已存在後,則回報為「建議」,並說明遷移成本。
Go + sqlc
sqlc 是 SQL 優先:結構來自遷移檔案,因此上述所有規則直接套用於遷移檔案。
- 將
sqlc.yaml中的schema指向遷移目錄(支援 goose、golang-migrate、dbmate 和 atlas 格式;向下遷移會被忽略)。切勿在遷移檔案旁保留獨立的結構檔案。 - 複數 snake_case 資料表會產生單數的 Go 結構:
order_items→OrderItem。列舉order_status→ 型別OrderStatus,附帶常數OrderStatusPending。 - 使用
sql_package: pgx/v5。覆寫uuid→github.com/google/uuid.UUID,並可選擇將timestamptz→time.Time(而非pgtype包裝器)。 - 使用
rename(url: URL,ip: IP)修正首字母縮寫,而非重新命名欄位。 - 查詢名稱使用 PascalCase 動詞 + 物件,並加上結果種類:
| 用途 | 範例 |
|---|---|
| 單行 | -- name: GetUserByEmail :one |
| 多行 | -- name: ListOrdersByCustomer :many |
| 插入 | -- name: CreateOrder :one + returning * |
| 更新 | -- name: UpdateOrderStatus :execrows |
| 刪除 | -- name: DeleteSession :exec |
| 大量載入 | -- name: CreateOrderItems :copyfrom |
- 使用
@name或sqlc.arg(name)命名參數,並使用sqlc.narg(name)表示可為空的參數;位置參數$1會產生Column1風格的 Go 欄位。 - 在聯結中使用
sqlc.embed(table),而非選取帶有臨時別名的欄位。 - 在查詢中勿使用
select *,除非是寫入操作的returning *,以確保查詢能承受結構變更。