完整 CRUD
前兩章已經能建立和查詢 Todo,這一章補上更新與刪除:先讀取實體,再修改或標記刪除,最後呼叫 SaveChangesAsync() 儲存。
CRUD 是 Create、Read、Update、Delete 的縮寫,也就是建立、讀取、更新、刪除。驗證、路由群組和錯誤處理沿用前面的做法。為集中講解寫入,本章清單回傳全部 Todo,不保留篩選分頁;分類預置 Work(1)和 Life(2),暫不提供增刪 API。
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Categories.AnyAsync())
{
db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
await db.SaveChangesAsync();
}
}
var todos = app.MapGroup("/todos").WithTags("Todos");
todos.MapGet("/", async (TodoDbContext db) =>
await db.Todos.AsNoTracking().OrderBy(t => t.Id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());
todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is null ? TypedResults.NotFound()
: TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});
todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}",
new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400);
todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
todo.Title = input.Title;
todo.Done = input.Done;
todo.CategoryId = input.CategoryId;
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).ProducesProblem(400);
todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
app.Run();using System.ComponentModel.DataAnnotations;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Todo> Todos { get; set; } = [];
}
public record CreateTodo(
[Required, StringLength(100)] string Title,
[Range(1, int.MaxValue)] int CategoryId);
public record ReplaceTodo(
[Required, StringLength(100)] string Title,
bool Done,
[Range(1, int.MaxValue)] int CategoryId);
public record TodoResponse(int Id, string Title, bool Done, int CategoryId);using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}執行與驗證
停止上一章服務,在儲存庫根目錄執行:
cd samples/17-crud
dotnet run本章使用 todos-17.db,首次執行階段 Todo 表為空。下面按順序操作,回應只摘錄關鍵回應標頭;資料庫已有資料時,編號會不同。
建立任務:
curl -i -X POST http://localhost:5080/todos -H "Content-Type: application/json" -d '{"title":"Write report","categoryId":1}'HTTP/1.1 201 Created
Location: /todos/1
Content-Type: application/json; charset=utf-8
{"id":1,"title":"Write report","done":false,"categoryId":1}把它改為已完成,並移到 Life 分類:
curl -i -X PUT http://localhost:5080/todos/1 -H "Content-Type: application/json" -d '{"title":"Write report","done":true,"categoryId":2}'HTTP/1.1 204 No Content204 沒有回應本文。重新讀取,才能看到儲存後的內容:
curl http://localhost:5080/todos/1{"id":1,"title":"Write report","done":true,"categoryId":2}刪除並再次查詢:
curl -i -X DELETE http://localhost:5080/todos/1
curl -i http://localhost:5080/todos/1前一個回應是 204;後一個是 404,回應本文如下,traceId 的值每次不同:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"request-trace-id"}請求中允許修改哪些欄位
CreateTodo 只允許輸入 Title、CategoryId;ReplaceTodo 還允許修改 Done。Id 始終來自路由或資料庫,不從請求本文覆蓋。
為什麼不用 Todo 直接接收請求?如果將來實體增加內部欄位,直接接收整個實體可能讓用戶端也能修改它。單獨定義請求型別,就能明確限制可修改的欄位。
輸入上的 [Required]、[StringLength]、[Range] 沿用第 06 章的機制。輸入 record 保持 public,讓 .NET 10 驗證產生器處理它。
PUT 在這裡表示替換用戶端可編輯的完整狀態,請傳送 title、done、categoryId 三個欄位。它不是“只修改 JSON 中出現的欄位”:例如省略 done,反序列化後的值是 false,就會把目前完成狀態替換成 false。
先讀取,再修改,再儲存
PUT 的步驟是:
FindAsync(id)找到實體;不存在就回傳 404。- 查詢目標分類是否存在;不存在回傳帶說明的 400。
- 修改跟蹤中的實體屬性。
SaveChangesAsync()檢測變化並寫入資料庫。
這裡不需要再呼叫 Update(todo),因為 FindAsync 回傳的實體已經由目前上下文跟蹤。清單上的 AsNoTracking() 則用於唯讀場景;如果取回不跟蹤的物件後只修改屬性,直接儲存不會自動寫回它。基本儲存操作
刪除同理:Remove(todo) 標記實體待刪除,SaveChangesAsync() 才真正刪除資料庫中的記錄。刪除後再查同一編號,得到 404。
編號合法,不代表分類存在
[Range(1, int.MaxValue)] 只能檢查編號是正數,不能證明分類 99 確實存在。分類存在性需要查詢資料庫,所以它放在處理常式裡:
curl -i -X POST http://localhost:5080/todos -H "Content-Type: application/json" -d '{"title":"Invalid","categoryId":99}'回應為 400,正文如下:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Category not found","status":400,"traceId":"request-trace-id"}先查詢是為了回傳明確的“分類不存在”,資料庫的外來鍵約束仍會阻止寫入無效編號。本例不能刪除分類;以後若加上刪除 API,還要處理“檢查時存在,儲存前被刪掉”的情況。
.ProducesProblem(400) 將這類 400 回應補充到 OpenAPI 文件中,原因見第 09 章。
重複請求與同時修改
重複 PUT 相同內容,最終狀態相同;DELETE 多次後,資源仍是不存在的。這叫冪等性(idempotency),不要求每次狀態碼都相同,所以第一次 DELETE 的 204 和後一次的 404 並不矛盾。POST 則可能每次建立新資源。
本章還沒有檢查並行更新:如果兩個人讀到同一條 Todo 後各自修改,後儲存的值可能覆蓋前一次修改。下一章先解決存取權限,限制誰可以呼叫這些 API。
FastAPI 對照
這類似在 FastAPI 處理函式中查出 SQLAlchemy 實體、修改屬性,然後提交 Session。DTO 與資料庫實體分開,也對應 Pydantic 輸入/輸出模型與 ORM 模型各自負責一件事。
提示
想對照 INSERT、UPDATE、DELETE,可以查閱 EF Core / LINQ ↔ PostgreSQL 速查,附可執行的對照範例。
總結
- CRUD 組合建立、讀取、更新、刪除;不同操作使用相應的 HTTP 方法和狀態碼。
- 請求 DTO 限定可修改欄位,實體對應資料庫記錄,回應 DTO 決定回傳欄位。
- 跟蹤查詢後的實體可以直接修改,
SaveChangesAsync()檢測並儲存變化。 Remove只標記刪除,儲存後才生效;欄位驗證不能替代資料庫存在性檢查。- 本例 PUT 替換完整可編輯狀態,尚未處理並行更新衝突。
