程式開發

pydantic-models-py

依 Pydantic v2 的多模型模式建立 Base、Create、Update、Response 與 InDB 類別,統一欄位別名、更新語意及資料庫文件形狀,減少 API 契約混用。

查看來源 ↗
作者/來源Microsoft★ 2,983 GitHub Stars
測試版本1.0.0更新日期:2026-08-31T16:38:01Z
支援平台OpenAI · Claude本次實測平台
安裝方式npx skills add microsoft/skills
公平比較方式

三個問題,分別測載入前與載入後。

每次回答都在全新環境中產生。兩邊使用相同模型與設定,唯一差別是右側載入了這個 Skill。

模型版本gpt-5.6-sol帳號方案ChatGPT Plus思考強度medium執行工具codex-cli-native-skill
真實測試使用本機已登入的 ChatGPT Plus 實際執行,完成於 2026年9月2日。所有檢查通過後才公開,回答未經人工改寫。
三階段測試3 個問題全部完成版本核對3/3 通過環境清理9/9 通過測試隔離每回合全新環境
01
基礎題

建立基本多模型結構

測試問題

使用 Pydantic v2 為 Project 建立 Base、Create、Update、Response、InDB 五個模型。欄位有 name、description、workspace_id;API JSON 使用 workspaceId,Update 欄位都應可選。請提供完整 Python 程式碼。

載入前 · 0 個 Skill載入後 · 版本已核對再次確認 · 0 個 Skill已確認模型套用 Skill三個環境皆已清理
未載入 Skill324 tokens · 13994 ms
```python from uuid import UUID from pydantic import BaseModel, ConfigDict, Field class ProjectBase(BaseModel): model_config = ConfigDict( populate_by_name=True, from_attributes=True, ) name: str description: str | None = None workspace_id: UUID = Field(alias="workspaceId") class ProjectCreate(ProjectBase): pass class ProjectUpdate(BaseModel): model_config = ConfigDict( populate_by_name=True, from_attributes=True, ) name: str | None = None description: str | None = None workspace_id: UUID | None = Field(default=None, alias="workspaceId") class ProjectResponse(ProjectBase): id: UUID class ProjectInDB(ProjectResponse): pass ```
已載入 Skill +1067 tokens · 43491 ms
```python from pydantic import BaseModel, ConfigDict, Field class ProjectBase(BaseModel): model_config = ConfigDict( populate_by_name=True, serialize_by_alias=True, ) name: str description: str workspace_id: str = Field(alias="workspaceId") class ProjectCreate(ProjectBase): pass class ProjectUpdate(BaseModel): model_config = ConfigDict( populate_by_name=True, serialize_by_alias=True, ) name: str | None = None description: str | None = None workspace_id: str | None = Field(default=None, alias="workspaceId") class ProjectResponse(ProjectBase): model_config = ConfigDict( populate_by_name=True, serialize_by_alias=True, from_attributes=True, ) class ProjectInDB(ProjectResponse): doc_type: str = "project" ```
02
實務題

加入限制與時間欄位

測試問題

延續 Project 模型:name 長度 1 到 120、Create 的 workspace_id 必填、Response 增加 id/created_at/updated_at 並輸出 camelCase,Update 不得把 name 接受為空字串。請提供 Pydantic v2 程式碼並保持模型職責清楚。

載入前 · 0 個 Skill載入後 · 版本已核對再次確認 · 0 個 Skill已確認模型套用 Skill三個環境皆已清理
未載入 Skill716 tokens · 24124 ms
```python from datetime import datetime from typing import Annotated from uuid import UUID from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel ProjectName = Annotated[str, Field(min_length=1, max_length=120)] class ProjectBase(BaseModel): """Project 的共用欄位。""" name: ProjectName class ProjectCreate(ProjectBase): """建立 Project 時所需的輸入。""" workspace_id: UUID class ProjectUpdate(BaseModel): """部分更新 Project;未提供 name 代表不更新。""" name: ProjectName | None = None class ProjectResponse(ProjectBase): """對外回傳的完整 Project 資料。""" model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, serialize_by_alias=True, from_attributes=True, ) id: UUID workspace_id: UUID created_at: datetime updated_at: datetime ```
已載入 Skill +1490 tokens · 52590 ms
```python from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel class ProjectBase(BaseModel): """Fields shared by Project models.""" model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, serialize_by_alias=True, ) name: str = Field(min_length=1, max_length=120) description: str | None = None class ProjectCreate(ProjectBase): """Payload for creating a Project.""" workspace_id: str class ProjectUpdate(BaseModel): """Payload for partially updating a Project.""" model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, serialize_by_alias=True, ) name: str | None = Field(default=None, min_length=1, max_length=120) description: str | None = None class ProjectResponse(ProjectBase): """Public Project representation returned by the API.""" model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, serialize_by_alias=True, from_attributes=True, ) id: str workspace_id: str created_at: datetime updated_at: datetime class ProjectInDB(ProjectResponse): """Persisted Project document.""" doc_type: Literal["project"] = "project" ```
03
壓力題

避免更新模型陷阱

測試問題

審查這段 Pydantic v2 設計:ProjectUpdate 繼承 ProjectCreate,因此 workspace_id 仍為必填;ProjectResponse 直接加入 doc_type;所有 API 都回傳 ProjectInDB。請指出契約問題,並重構成較安全的多模型結構。

載入前 · 0 個 Skill載入後 · 版本已核對再次確認 · 0 個 Skill已確認模型套用 Skill三個環境皆已清理
未載入 Skill2728 tokens · 85647 ms
根據題述,主要契約問題有三項: 1. `ProjectUpdate` 繼承 `ProjectCreate` - PATCH 被迫提供 `workspace_id`,失去部分更新語意。 - 建立欄位與可更新欄位被錯誤耦合。 - 若 `workspace_id` 不應由一般更新移動,還會形成 mass-assignment/越權風險。 - 更新時必須使用 `model_dump(exclude_unset=True)`,才能區分「未提供」與「明確設為 `null`」。 2. `ProjectResponse` 直接加入 `doc_type` - 將搜尋索引或多型文件的 discriminator 洩漏到一般 Project API。 - 若型別只是 `str`,OpenAPI 無法保證其固定值。 - `doc_type` 應只存在於需要 discriminated union 的投影模型,並使用 `Literal["project"]`。 3. 所有 API 都回傳 `ProjectInDB` - 儲存層欄位成為公開契約,可能洩漏 `deleted_at`、內部狀態、版本號或其他未來欄位。 - 資料庫 schema 的變更會意外破壞 API。 - FastAPI 若沒有明確的 `response_model`,可能直接序列化內部欄位。 - 建立、讀取、列表、刪除等端點也不應被迫共享同一回應形狀。 較安全的 Pydantic v2 結構如下;實際業務欄位可依現有 schema 補入: ```python from datetime import datetime from typing import Annotated, Literal from uuid import UUID from pydantic import ( BaseModel, ConfigDict, Field, field_validator, model_validator, ) class ProjectCreate(BaseModel): """POST /projects 的輸入契約。""" model_config = ConfigDict( extra="forbid", str_strip_whitespace=True, ) workspace_id: UUID name: str = Field(min_length=1, max_length=200) description: str | None = Field(default=None, max_length=2_000) class ProjectUpdate(BaseModel): """PATCH /projects/{id} 的輸入契約;不得繼承 ProjectCreate。""" model_config = ConfigDict( extra="forbid", str_strip_whitespace=True, ) name: str | None = Field(default=None, min_length=1, max_length=200) description: str | None = Field(default=None, max_length=2_000) @field_validator("name") @classmethod def name_must_not_be_null(cls, value: str | None) -> str: if value is None: raise ValueError("name cannot be null") return value @model_validator(mode="after") def require_at_least_one_change(self) -> "ProjectUpdate": if not self.model_fields_set: raise ValueError("at least one field must be provided") return self class ProjectMove(BaseModel): """只有產品明確允許移動專案時才提供獨立操作。""" model_config = ConfigDict(extra="forbid") target_workspace_id: UUID class ProjectResponse(BaseModel): """一般公開 API 的輸出契約。""" model_config = ConfigDict(from_attributes=True) id: UUID workspace_id: UUID name: str description: str | None created_at: datetime updated_at: datetime class ProjectInDB(BaseModel): """僅限 repository/service 內部使用,不作為 API response_model。""" model_config = ConfigDict(from_attributes=True) id: UUID workspace_id: UUID name: str description: str | None created_at: datetime updated_at: datetime deleted_at: datetime | None = None version: int class ProjectDocumentResponse(ProjectResponse): """只用於搜尋結果或跨文件型別的多型回應。""" doc_type: Literal["project"] = "project" class UserDocumentResponse(BaseModel): model_config = ConfigDict(from_attributes=True) doc_type: Literal["user"] = "user" id: UUID display_name: str SearchDocument = Annotated[ ProjectDocumentResponse | UserDocumentResponse, Field(discriminator="doc_type"), ] ``` PATCH 的更新資料應如此產生: ```python changes = payload.model_dump(exclude_unset=True) updated_record = repository.update(project_id, changes) return ProjectResponse.model_validate(updated_record) ``` FastAPI 端點應明確指定各自契約: ```python @router.post( "/projects", response_model=ProjectResponse, status_code=201, ) def create_project(payload: ProjectCreate) -> ProjectResponse: record = service.create_project(payload) return ProjectResponse.model_validate(record) @router.patch( "/projects/{project_id}", response_model=ProjectResponse, ) def update_project( project_id: UUID, payload: ProjectUpdate, ) -> ProjectResponse: record = service.update_project( project_id, payload.model_dump(exclude_unset=True), ) return ProjectResponse.model_validate(record) @router.get( "/projects/{project_id}", response_model=ProjectResponse, ) def get_project(project_id: UUID) -> ProjectResponse: return ProjectResponse.model_validate(service.get_project(project_id)) @router.delete( "/projects/{project_id}", status_code=204, ) def delete_project(project_id: UUID) -> None: service.delete_project(project_id) ``` 核心邊界是:`ProjectCreate`、`ProjectUpdate` 屬於命令契約;`ProjectResponse` 屬於公開讀取契約;`ProjectInDB` 僅屬於持久化層;帶 `doc_type` 的模型則是特定搜尋/多型投影,不應成為所有 Project API 的預設回應。
已載入 Skill +2567 tokens · 83998 ms
## 契約問題 1. `ProjectUpdate` 繼承 `ProjectCreate` - `workspace_id` 被意外變成更新時的必填欄位,無法正確表達 PATCH。 - 若 API 接受該值,還可能允許專案跨 workspace 移動,造成越權風險。 - 建立專用欄位與可更新欄位被錯誤綁定。 2. `ProjectResponse` 包含 `doc_type` - 將資料庫 discriminator 洩漏到公開 API 與 OpenAPI 契約。 - 用戶端會依賴儲存實作,未來更換資料庫或欄位名稱就可能成為 breaking change。 3. API 回傳 `ProjectInDB` - 持久層模型與公開契約耦合。 - 未來加入內部欄位時,可能在沒有察覺的情況下外洩。 - API 應明確回傳 `ProjectResponse`;`ProjectInDB` 僅限 repository/database 層使用。 ## 建議結構 ```python """Project API and persistence models.""" from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field, model_validator class ProjectBase(BaseModel): """Public fields shared by create and response models.""" model_config = ConfigDict( populate_by_name=True, extra="forbid", ) name: str = Field(min_length=1, max_length=200) description: str | None = Field(default=None, max_length=2000) class ProjectCreate(ProjectBase): """POST request body.""" workspace_id: str = Field( min_length=1, alias="workspaceId", ) class ProjectUpdate(BaseModel): """PATCH request body; omitted fields remain unchanged.""" model_config = ConfigDict( populate_by_name=True, extra="forbid", ) name: str | None = Field(default=None, min_length=1, max_length=200) description: str | None = Field(default=None, max_length=2000) @model_validator(mode="after") def reject_null_name(self) -> "ProjectUpdate": # name may be omitted, but explicitly setting it to null is invalid. if "name" in self.model_fields_set and self.name is None: raise ValueError("name cannot be null") return self class ProjectResponse(ProjectBase): """Stable public API response.""" model_config = ConfigDict( populate_by_name=True, from_attributes=True, extra="forbid", ) id: str = Field(min_length=1) workspace_id: str = Field(min_length=1, alias="workspaceId") author_id: str = Field(min_length=1, alias="authorId") created_at: datetime = Field(alias="createdAt") updated_at: datetime | None = Field(default=None, alias="updatedAt") class ProjectInDB(ProjectResponse): """Internal database document; never used as an API response model.""" doc_type: Literal["project"] = "project" ``` 更新時必須使用 `exclude_unset=True`,才能區分「未提供」和「明確設為 `null`」: ```python changes = update.model_dump( exclude_unset=True, by_alias=False, ) ``` API 與持久層契約應分開: ```python @router.post("/projects", response_model=ProjectResponse) async def create_project(payload: ProjectCreate) -> ProjectResponse: document: ProjectInDB = await repository.create(payload) return ProjectResponse.model_validate( document.model_dump(exclude={"doc_type"}) ) @router.patch("/projects/{project_id}", response_model=ProjectResponse) async def update_project( project_id: str, payload: ProjectUpdate, ) -> ProjectResponse: document: ProjectInDB = await repository.update( project_id, payload.model_dump(exclude_unset=True), ) return ProjectResponse.model_validate( document.model_dump(exclude={"doc_type"}) ) ``` 最終邊界應為: - `POST` 請求:`ProjectCreate` - `PATCH` 請求:`ProjectUpdate` - 所有公開回應:`ProjectResponse` - repository/database 文件:`ProjectInDB` - `workspace_id`、`author_id`、時間戳與 `doc_type` 不得由一般更新請求修改
測試紀錄

這次測試記錄了什麼?

固定不變兩邊使用相同的模型版本、帳號方案、系統指令、思考強度與測試問題。

分開測試每次回答都使用全新環境。載入前不含任何 Skill,載入後只含本頁指定的 Skill。

完整才公開三題的所有步驟與環境清理都成功後,才顯示結果。若無法確認模型是否套用 Skill,也會清楚標示。