ข้ามไปยังเนื้อหา

Columns

module columns: model.rs, repo.rs, service.rs, handlers.rs, mod.rs ใต้ taskflow/backend/api/src/columns/ สาม endpoint — สร้าง column บน board, เปลี่ยนชื่อ column, ลบ column — ไม่มีตัวไหนต้องการ authorization primitive ใหม่เลย เพราะ boards::service::assert_member จากบทที่แล้วครอบคลุมทุกตัวอยู่แล้ว

columns/model.rs มีบรรทัดเดียว: re-export Column จาก boards::model ที่นิยามไว้แล้วเพื่อให้ BoardTree มีอยู่ได้ตั้งแต่บท boards ไฟล์อื่นทุกไฟล์ใน module นี้ — repo.rs, service.rs, handlers.rs — เป็นโค้ดใหม่เฉพาะ column จริง ๆ

เลขคณิต position ของ create_columnMAX(position) + 1.0 — คือการใช้กลยุทธ์ fractional-position จาก indexes-ordering จริงครั้งแรกนอกเหนือจากตัวอย่าง SQL ของบทนั้นเอง column ใหม่เอี่ยมจะไปอยู่ ท้าย รายการ column ของ board เสมอ (ซ้ายไปขวา) ซึ่งตรงกับกรณี “insert ที่ท้ายสุด” พอดี: ก้าวเลยค่าสูงสุดปัจจุบันไปหนึ่ง หรือเริ่มที่ 1.0 ถ้า board ยังไม่มี column เลย (MAX(position) บนเซตว่างคืน SQL NULL ซึ่ง sqlx::query_scalar แม็ปเป็น Option<f64>::Noneunwrap_or(0.0) + 1.0 แปลงค่านั้นเป็น 1.0 ค่าเริ่มต้นเดียวกับที่ indexes-ordering กำหนดไว้สำหรับรายการว่าง)

การใช้ boards::service::assert_member ซ้ำแทนที่จะเขียน columns::service::assert_member เองคือผลตอบแทนที่สัญญาไว้ในบทที่แล้ว: คำถาม authorization ของ column ไม่เคยเป็น “user นี้แตะ column นี้ได้ไหม” แบบแยกเดี่ยว แต่เป็น “user นี้เป็นสมาชิกของ board ที่ column นี้สังกัดอยู่ไหม” เสมอ ที่เป็นคำถามเดียวกับที่ boards::service::assert_member ตอบได้อยู่แล้วถ้าให้ board_id มา ดังนั้น columns::service แค่ต้องทำขั้นตอนเพิ่มหนึ่งขั้นก่อน: หาว่า column นี้เป็นของ board ไหน

columns::service resolve board_id ผ่าน repo::find_column ก่อน delegate ไปยัง boards_service::assert_member (ตัวที่เราใช้) เทียบกับให้ผู้เรียกส่ง board_id มาตรง ๆ ทุก route ของ column

  • ข้อดี: PATCH /columns/:id และ DELETE /columns/:id ต้องการแค่ id ของ column เองใน URL — client ที่ดึง BoardTree มาแล้วและมี id ของ column ตัวใดตัวหนึ่งอยู่ในมือ ทำงานกับ column นั้นได้ตรง ๆ ไม่ต้อง track และส่ง board ต้นทางมาด้วย ตรงกับเหตุผล “ID ไม่ต้องมี parent ใน URL เพื่อแยกแยะ” จาก design
  • ข้อเสีย: ทุก PATCH/DELETE บน column ตอนนี้จ่ายค่า SELECT เพิ่มหนึ่งครั้ง (find_column) แค่เพื่อหา board_id ก่อนที่ SELECT ของ authorization check เองจะรันด้วยซ้ำ — สอง round trip ในที่ที่ออกแบบแบบมี board_id ใน URL จะต้องการแค่หนึ่ง นี่คือ trade-off “ความสะดวกของ authorization กับ round trip” เดียวกับที่ design ตัดสินใจไปแล้วสำหรับ boards::service เอง เพียงแค่ใช้ลึกลงไปอีกชั้น

position ของ create_column ผ่าน MAX(position) + 1.0 ใน Rust (ตัวที่เราใช้) เทียบกับ COALESCE(MAX(position), 0) + 1 ใน SQL ที่คำนวณข้างใน INSERT

  • ข้อดี: repo::max_position เป็น function เล็ก ๆ อ่านง่ายอิสระ ทดสอบได้อิสระ คืน Option<f64> ธรรมดา — service::create_column ตัดสินใจว่า “ยังไม่มี column เลย” (None) หมายถึงอะไร (1.0) ด้วย Rust ธรรมดา ไม่ใช่ใน SQL expression ที่ต้องอ่านจากขวาไปซ้ายเพื่อเข้าใจ วิธีสองคิวรียังตรงกับ pattern เดียวกันของ cards::service::create_card อีกบทข้างหน้า ทำให้ logic “ต่อท้าย” ของทั้งสอง resource ดูสอดคล้องกันด้วยตา
  • ข้อเสีย: สองคิวรี — หนึ่ง SELECT MAX(position), หนึ่ง INSERT — แทนที่จะเป็นคิวรีเดียวทำทั้งคู่ และมี race แคบ ๆ ทางทฤษฎี: การเรียก create_column พร้อมกันสองครั้งบน board ว่างเดียวกันอาจอ่าน MAX(position) = NULL ทั้งคู่และคำนวณ 1.0 ทั้งคู่ ทำให้ column ใหม่สองตัวไปอยู่ที่ position เดียวกัน สำหรับรูปแบบการใช้งานของ TaskFlow (คนเดียวเพิ่มทีละ column ไม่ใช่ bulk-import ที่ concurrency สูง) นี่คือความเสี่ยงที่ยอมรับได้ — pass renormalization จาก indexes-ordering จะเก็บกวาด tie ที่เกิดขึ้นในรอบถัดไปที่รัน เหมือนที่จัดการกับ float-precision exhaustion อยู่แล้ว
pub use crate::boards::model::Column;

re-export บรรทัดเดียว ไม่มีอะไรอื่นในไฟล์นี้ นิยาม #[derive(Serialize, FromRow)] หลักของ Columnid, board_id, title, position — อยู่ใน boards::model ที่ครอบคลุมในบทที่แล้ว

use sqlx::PgPool;
use uuid::Uuid;
use crate::error::AppResult;
use super::model::Column;
pub async fn find_column(db: &PgPool, id: Uuid) -> AppResult<Option<Column>> {
let column = sqlx::query_as::<_, Column>(
"SELECT id, board_id, title, position FROM columns WHERE id = $1",
)
.bind(id)
.fetch_optional(db)
.await?;
Ok(column)
}
pub async fn max_position(db: &PgPool, board_id: Uuid) -> AppResult<Option<f64>> {
let max: Option<f64> =
sqlx::query_scalar("SELECT MAX(position) FROM columns WHERE board_id = $1")
.bind(board_id)
.fetch_one(db)
.await?;
Ok(max)
}
pub async fn insert_column(
db: &PgPool,
id: Uuid,
board_id: Uuid,
title: &str,
position: f64,
) -> AppResult<Column> {
let column = sqlx::query_as::<_, Column>(
"INSERT INTO columns (id, board_id, title, position) VALUES ($1, $2, $3, $4)
RETURNING id, board_id, title, position",
)
.bind(id)
.bind(board_id)
.bind(title)
.bind(position)
.fetch_one(db)
.await?;
Ok(column)
}
pub async fn update_title(db: &PgPool, id: Uuid, title: &str) -> AppResult<Option<Column>> {
let column = sqlx::query_as::<_, Column>(
"UPDATE columns SET title = $2 WHERE id = $1
RETURNING id, board_id, title, position",
)
.bind(id)
.bind(title)
.fetch_optional(db)
.await?;
Ok(column)
}
pub async fn delete_column(db: &PgPool, id: Uuid) -> AppResult<bool> {
let result = sqlx::query("DELETE FROM columns WHERE id = $1")
.bind(id)
.execute(db)
.await?;
Ok(result.rows_affected() > 0)
}

max_position ใช้ fetch_one ไม่ใช่ fetch_optionalSELECT MAX(...) บน table ที่ไม่มีแถวตรงเงื่อนไขเลยก็ยังคืนมาหนึ่งแถวเสมอ โดยคอลัมน์เดียวในแถวนั้นเป็น SQL NULL ต่างจาก fetch_optional ของ find_column ที่ศูนย์แถวหมายถึง query ไม่คืนอะไรมาให้ fetch เลย

use sqlx::PgPool;
use uuid::Uuid;
use crate::{
boards::service as boards_service,
error::{AppError, AppResult},
};
use super::model::Column;
use super::repo;
pub async fn create_column(
db: &PgPool,
user_id: Uuid,
board_id: Uuid,
title: String,
) -> AppResult<Column> {
boards_service::assert_member(db, user_id, board_id).await?;
let position = repo::max_position(db, board_id).await?.unwrap_or(0.0) + 1.0;
repo::insert_column(db, Uuid::new_v4(), board_id, &title, position).await
}
pub async fn update_column(
db: &PgPool,
user_id: Uuid,
column_id: Uuid,
title: String,
) -> AppResult<Column> {
let column = repo::find_column(db, column_id)
.await?
.ok_or(AppError::NotFound)?;
boards_service::assert_member(db, user_id, column.board_id).await?;
repo::update_title(db, column_id, &title)
.await?
.ok_or(AppError::NotFound)
}
pub async fn delete_column(db: &PgPool, user_id: Uuid, column_id: Uuid) -> AppResult<()> {
let column = repo::find_column(db, column_id)
.await?
.ok_or(AppError::NotFound)?;
boards_service::assert_member(db, user_id, column.board_id).await?;
if repo::delete_column(db, column_id).await? {
Ok(())
} else {
Err(AppError::NotFound)
}
}

create_column มี board_id จาก URL อยู่แล้ว (POST /boards/:id/columns) เลยเรียก assert_member ตรง ๆ update_column กับ delete_column มีแค่ column_id เลยเรียก repo::find_column ก่อน — column ที่หายไปคือ AppError::NotFound ก่อนจะเริ่มเช็ค authorization ด้วยซ้ำ ตรงกับลำดับ 404-ก่อน-403 ที่ design กำหนดไว้

use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::{auth::middleware::AuthUser, error::AppResult, state::AppState};
use super::model::Column;
use super::service;
#[derive(Debug, Deserialize)]
pub struct CreateColumnRequest {
pub title: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateColumnRequest {
pub title: String,
}
pub async fn create_column(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(board_id): Path<Uuid>,
Json(body): Json<CreateColumnRequest>,
) -> AppResult<(StatusCode, Json<Column>)> {
let column = service::create_column(&state.db, user_id, board_id, body.title).await?;
Ok((StatusCode::CREATED, Json(column)))
}
pub async fn update_column(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(column_id): Path<Uuid>,
Json(body): Json<UpdateColumnRequest>,
) -> AppResult<Json<Column>> {
let column = service::update_column(&state.db, user_id, column_id, body.title).await?;
Ok(Json(column))
}
pub async fn delete_column(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(column_id): Path<Uuid>,
) -> AppResult<StatusCode> {
service::delete_column(&state.db, user_id, column_id).await?;
Ok(StatusCode::NO_CONTENT)
}

Path(board_id) ของ create_column ดึงส่วน :id จาก /boards/:id/columns — ชื่อ path parameter เดียวกันตัวอักษร :id ที่ update_column/delete_column ดึงจาก /columns/:id ในชื่อ column_id Axum ไม่สนว่าทั้งสอง route ใช้ชื่อ segment ตัวอักษร :id เหมือนกัน — การ bind Path<Uuid> ของแต่ละ handler เองที่ตัดสินใจว่าจะเรียกค่าที่ดึงมาว่าอะไร

pub mod handlers;
pub mod model;
pub mod repo;
pub mod service;
use axum::{
routing::{patch, post},
Router,
};
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/boards/:id/columns", post(handlers::create_column))
.route(
"/columns/:id",
patch(handlers::update_column).delete(handlers::delete_column),
)
}
mod auth;
mod boards;
mod columns;
mod config;
mod db;
mod error;
mod state;
let app = Router::new()
.route("/health", get(health))
.nest("/auth", auth::routes())
.merge(boards::routes())
.merge(columns::routes())
.layer(cors)
.with_state(state);
Terminal window
cargo check -p api

ใช้ $TOKEN และ $BOARD_ID จากส่วน verify ของบท boards ซ้ำ (สร้าง board ใหม่ก่อนถ้าคุณลบตัวก่อนหน้าไปแล้ว) สร้าง column:

Terminal window
COLUMN_ID=$(curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"To Do"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')

สร้าง column ที่สองแล้วยืนยันว่าไปต่อท้ายตัวแรก:

Terminal window
curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"In Progress"}'
{"id":"...","board_id":"...","title":"In Progress","position":2.0}

ดึง board tree — column ทั้งสองปรากฏแล้ว เรียงตาม position แต่ละอันมี cards array ว่างเปล่า:

Terminal window
curl -s http://localhost:8080/boards/$BOARD_ID -H "Authorization: Bearer $TOKEN"

เปลี่ยนชื่อ column แรก:

Terminal window
curl -s -X PATCH http://localhost:8080/columns/$COLUMN_ID \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Backlog"}'

ลบทิ้ง:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:8080/columns/$COLUMN_ID \
-H "Authorization: Bearer $TOKEN"
204

คุณสร้าง module columnsmodel.rs re-export Column จาก boards::model, query ของ repo.rs แบบ insert-at-end ด้วย MAX(position) + 1.0, service.rs ที่ resolve board_id ของ column ก่อนจะ delegate authorization check ทุกอันไปที่ boards::service::assert_member, และ handlers.rs บาง ๆ สามตัว columns::routes() รวมเข้า main.rs คู่กับ boards::routes() นี่คือ module แรกในคอร์สที่ไม่เพิ่ม authorization primitive ใหม่เลย — พิสูจน์ว่า assert_member/assert_owner จากบท boards generalize ไปยัง resource ไหนก็ได้ที่สุดท้ายย้อนกลับไปที่ board ได้อย่างสะอาด ถัดไป cards ตาม pattern เดียวกันลึกลงไปอีกชั้น — board ของ card resolve ผ่าน column ที่ card อยู่ ไม่ใช่ตรง ๆ