Reading history
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ฝั่ง read และ delete ของ app/routers/workouts.py เหนือ session ที่ Logging sessions → เขียนไว้:
GET /workouts— workout history ของ caller เอง ใหม่สุดก่อนGET /workouts/{id}— session เดียวพร้อม set ทุกตัวDELETE /workouts/{id}— ลบ session หนึ่งอันของ caller
ทุก route scope ให้ เจ้าของ ต่างจาก exercise catalog ตรงที่ workout ไม่เคยแชร์กับใคร — เป็น training history ส่วนตัว — ดังนั้นไม่มีการแยก public/private ให้ต้องคิด: workout อันหนึ่งเป็นของคุณ หรือในมุมของ API ก็คือไม่มีอยู่ เราบังคับสิ่งนั้นด้วยการใส่ user_id ไว้ใน WHERE clause ของทุก query ซึ่งเปลี่ยน ownership ให้เป็นคุณสมบัติของตัว fetch เอง
วิธีที่สะอาดที่สุดในการบังคับ “คุณแตะได้แค่ workout ของตัวเอง” คือทำให้ เป็นไปไม่ได้ที่จะ fetch ของคนอื่นตั้งแต่แรก แทนที่จะโหลด workout ด้วย id แล้ว ค่อย เทียบ user_id ของแถวนั้นกับ caller (fetch-then-check) ทุก query filter บน Workout.user_id == caller ตั้งแต่ต้น workout ของ user คนอื่นก็ไม่กลับมาเลย — scalar_one_or_none() คืน None — และ route คืน 404 ไม่มี authorization branch แยกให้ลืม ไม่มีช่วงที่แถวอยู่ใน memory ก่อนเช็ค และเคส “ไม่ใช่ของคุณ” กับ “ไม่มีอยู่” ยุบรวมเป็น 404 ที่ซื่อสัตย์เดียวกัน: history ของคุณมีหรือไม่มี และคุณ probe หาการมีอยู่ของ session คนอื่นไม่ได้ (นี่คือภาพสะท้อนของ exercise catalog ที่ exercise สาธารณะแชร์กันจริงจึงต้องมี 403 ชัด ๆ workout ไม่แชร์อะไรเลย filter จึงเป็นเรื่องราวทั้งหมด)
ใหม่สุดก่อน คือ default ที่หน้าจอ history ต้องการ: order_by(Workout.performed_at.desc()) คนที่เปิด FitTrack สนใจว่าวันนี้ทำอะไร ไม่ใช่ session แรกในชีวิต ดังนั้น API คืนอันล่าสุดไว้บนสุดแทนที่จะให้ client จัดเรียงใหม่
และเหมือน write path การอ่าน workout พร้อม set แปลว่าต้อง eager-load relationship sets ด้วย selectinload ภายใต้ async SQLAlchemy นี่ไม่ใช่ทางเลือก: lazy load ที่ trigger ตอน serialize response — นอก await context ของ session — raise MissingGreenlet แทนที่จะรัน query เงียบ ๆ selectinload fetch set ทั้งหมดสำหรับ workout ที่คืนมาใน query เพิ่มเดียวตั้งแต่ต้น ดังนั้น WorkoutRead ที่ซ้อนกัน serialize ได้สะอาดและคุณเลี่ยง N+1 ที่ lazy load แบบ per-workout ไร้เดียงสาจะก่อ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Ownership as a WHERE user_id = :me filter vs. fetch by id, then check ownership in the handler
- Pros: query คืนได้แค่แถวของ caller เท่านั้น ดังนั้นไม่มี authorization step ให้ลืม และไม่มีช่วงที่ข้อมูลของ user คนอื่นนั่งอยู่ใน memory “ไม่ใช่ของคุณ” กับ “ไม่มีอยู่” กลายเป็น
404เดียวกัน ซึ่งยังหยุด client ที่ probe ว่า id หนึ่งมีอยู่สำหรับคนอื่นไหม และ filter เดียวกันขับเคลื่อน list, get และ delete ได้เหมือนกัน - Cons: คุณแยก “workout นี้มีอยู่แต่เป็นของคนอื่น” ออกจาก “ไม่มี workout นี้” ไม่ได้ — ซึ่งคือสิ่งที่คุณต้องการเป๊ะสำหรับข้อมูล private แต่จะผิดสำหรับ resource ที่ใช้ร่วมกันที่
403ซื่อสัตย์กว่า (exercise catalog) pattern นี้ถูกต้องเพราะ workout ไม่เคยถูกใช้ร่วมกันเลย
selectinload eager loading vs. lazy-loading workout.sets on access
- Pros: set ทั้งหมดโหลดใน query เพิ่มเดียวก่อน serialization ดังนั้น response ที่ซ้อนกันถูกต้องและไม่มี N+1 และใช้ได้ภายใต้ async SQLAlchemy ที่ lazy loading ตอน serialize response error เอาดื้อ ๆ
- Cons: คุณ fetch set แม้กับ caller ที่ต้องการแค่ header ของ workout (over-fetch เล็กน้อยบน list endpoint) และคุณต้องจำ
.options(...)บนทุก query ที่คืน workout เมื่อ body ของ workout รวม set อยู่แล้ว การ eager-load ก็คือสิ่งที่ response ต้องการอยู่ดี
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. app/repositories/workout.py
หัวข้อที่มีชื่อว่า “1. app/repositories/workout.py”เพิ่มอีกสาม method บน WorkoutRepo แต่ละตัวพก filter user_id list_for_user กับ get_for_user eager-load sets ส่วน delete พึ่ง cascade ของ model เพื่อลบ child set ไปพร้อม parent
# app/repositories/workout.py — add to WorkoutRepo.from sqlalchemy import selectfrom sqlalchemy.orm import selectinload
class WorkoutRepo: # ... __init__, create_with_sets from the previous lesson ...
async def list_for_user(self, user_id: uuid.UUID) -> list[Workout]: """The caller's history, newest first, each with its sets loaded.""" result = await self.session.execute( select(Workout) .where(Workout.user_id == user_id) .order_by(Workout.performed_at.desc()) .options(selectinload(Workout.sets)) ) return list(result.scalars().all())
async def get_for_user( self, workout_id: uuid.UUID, user_id: uuid.UUID ) -> Workout | None: """One session — but only if it's the caller's. Ownership is in the WHERE clause, so someone else's workout returns None (→ 404).""" result = await self.session.execute( select(Workout) .where(Workout.id == workout_id, Workout.user_id == user_id) .options(selectinload(Workout.sets)) ) return result.scalar_one_or_none()
async def delete(self, workout: Workout) -> None: """Delete the workout; the cascade removes its sets too.""" await self.session.delete(workout) await self.session.commit()2. app/routers/workouts.py
หัวข้อที่มีชื่อว่า “2. app/routers/workouts.py”GET /workouts ไม่ต้องเช็ค id — filter scope ให้เรียบร้อยแล้ว ส่วน route ของ workout เดียว fetch ผ่าน get_for_user และ 404 เมื่อ None ดังนั้น ownership กับ existence เป็น branch เดียวกัน
# app/routers/workouts.py — add to the router from the previous lesson.from fastapi import HTTPException
@router.get("", response_model=list[WorkoutRead])async def list_workouts( user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> list[Workout]: """The caller's own workout history, newest first.""" return await WorkoutRepo(session).list_for_user(user_id)
@router.get("/{workout_id}", response_model=WorkoutRead)async def get_workout( workout_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> Workout: workout = await WorkoutRepo(session).get_for_user(workout_id, user_id) if workout is None: # not yours or not there — same 404 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Workout not found") return workout
@router.delete("/{workout_id}", status_code=status.HTTP_204_NO_CONTENT)async def delete_workout( workout_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> None: repo = WorkoutRepo(session) workout = await repo.get_for_user(workout_id, user_id) if workout is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Workout not found") await repo.delete(workout)ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ขอ token แล้ว log สัก session สองอัน (ดู Logging sessions →) เพื่อให้มี history ให้อ่าน จากนั้น list ออกมา — ใหม่สุดก่อน:
TOKEN=$(curl -s "http://127.0.0.1:54321/auth/v1/token?grant_type=password" \ -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"you@example.com","password":"password123"}' | jq -r .access_token)
curl -s localhost:8000/workouts -H "Authorization: Bearer $TOKEN" \ | jq 'map({id, performed_at, sets: (.sets | length)})'[ { "id": "7c2f…", "performed_at": "2026-07-14T18:05:00Z", "sets": 3 }, { "id": "3d9a…", "performed_at": "2026-07-14T09:30:00Z", "sets": 2 }]session 18:05 เรียงอยู่เหนืออัน 09:30 — order_by(performed_at.desc()) ทำงานอยู่ ดึง session เดียวแล้วยืนยันว่า set กลับมาซ้อนอยู่ข้างใน:
WID=$(curl -s localhost:8000/workouts -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')curl -s localhost:8000/workouts/$WID -H "Authorization: Bearer $TOKEN" \ | jq '{id, sets: [.sets[] | {set_index, reps, weight_kg}]}'{ "id": "7c2f…", "sets": [ { "set_index": 0, "reps": 8, "weight_kg": 60.0 }, { "set_index": 1, "reps": 8, "weight_kg": 60.0 }, { "set_index": 2, "reps": 6, "weight_kg": 65.0 } ]}ยืนยัน ownership scoping — id มั่ว ๆ (หรือ workout ของ user คนอื่น) เป็น 404 ไม่เคยรั่ว:
curl -s -o /dev/null -w "%{http_code}\n" \ localhost:8000/workouts/00000000-0000-0000-0000-000000000000 \ -H "Authorization: Bearer $TOKEN"404ลบ session แล้วพิสูจน์ว่าหายไปจริงและ list หดลง:
curl -s -o /dev/null -w "delete: %{http_code}\n" -X DELETE \ localhost:8000/workouts/$WID -H "Authorization: Bearer $TOKEN"curl -s localhost:8000/workouts -H "Authorization: Bearer $TOKEN" | jq lengthdelete: 2041ตรวจสอบความเข้าใจ:
- การใส่
user_idในWHEREclause ทำให้ “ไม่ใช่ของคุณ” กับ “ไม่มีอยู่” เป็น404เดียวกัน ทำไมนั่นคือผลลัพธ์ที่ถูกสำหรับ workout ในเมื่อ exercise catalog จงใจคืน403สำหรับ “ไม่ใช่ของคุณ”? - async SQLAlchemy raise error อะไรถ้าคุณทิ้ง
selectinloadแล้วปล่อยให้WorkoutReadlazy-loadworkout.setsตอน serialization? delete_workoutเรียกget_for_userก่อนลบ ทั้งที่ยิง delete ที่ filter ด้วยuser_idตรง ๆ ได้ การ fetch ก่อนให้อะไรคุณ?- แถว child
WorkoutSetหายไปตอนลบ workout แม่ แต่ delete ลบแค่Workoutอะไรทำให้ set หายตามไปด้วย?
ฝั่ง read ของ workout ถูก scope ให้เจ้าของโดยโครงสร้าง: GET /workouts คืน history ของ caller เรียงใหม่สุดก่อน, GET /workouts/{id} คืน session เดียวพร้อม set ทั้งหมด และ DELETE /workouts/{id} ลบทีละอัน — ทั้งหมด filter บน Workout.user_id == caller ภายในตัว query ดังนั้น workout ของ user คนอื่นแยกไม่ออกจากอันที่ไม่มีอยู่ และทั้งคู่คืน 404 pattern filter-by-owner นั้นแทนที่ authorization branch แบบ fetch-then-check และลืมไม่ได้ selectinload eager-load sets เพื่อให้ response ที่ซ้อนกัน serialize ได้ถูกต้องภายใต้ async และ cascade ของ model ลบ set ของ workout ไปพร้อมกันตอน delete คุณตรวจสอบการเรียงใหม่สุดก่อน, set ที่ซ้อน, 404 ของ ownership และ delete ด้วย curl นั่นทำให้ Workouts API เสร็จสมบูรณ์ — ต่อไป Progress & Stats → เปลี่ยน history ที่ log ไว้นี้ให้เป็นข้อมูลเชิงลึก: personal record, volume รายสัปดาห์ และ trend ต่อ exercise คำนวณด้วย SQL aggregation