-
-
Notifications
You must be signed in to change notification settings - Fork 0
필터링
엔티티 필터링을 위한 타입 안전 쿼리 구조체를 생성합니다. 필터링은 컴파일 타임 안전성으로 페이지네이션, 검색, 범위 쿼리를 가능하게 합니다.
#[derive(Entity)] #[entity(table = "products")] pub struct Product { #[id] pub id: Uuid, #[field(create, update, response)] #[filter] pub name: String, #[field(create, update, response)] #[filter(like)] pub description: String, #[field(create, update, response)] #[filter(range)] pub price: i64, #[field(create, response)] #[filter] pub category_id: Uuid, #[field(response)] #[auto] #[filter(range)] pub created_at: DateTime<Utc>, }
/// Product 엔티티 필터링을 위한 쿼리 파라미터. #[derive(Debug, Clone, Default)] pub struct ProductQuery { /// name 정확히 일치 필터. pub name: Option<String>, /// description 패턴 필터 (ILIKE). pub description: Option<String>, /// 최소 가격 필터. pub price_from: Option<i64>, /// 최대 가격 필터. pub price_to: Option<i64>, /// category_id 정확히 일치 필터. pub category_id: Option<Uuid>, /// 최소 created_at 필터. pub created_at_from: Option<DateTime<Utc>>, /// 최대 created_at 필터. pub created_at_to: Option<DateTime<Utc>>, /// 최대 결과 수. pub limit: Option<i64>, /// 건너뛸 결과 수. pub offset: Option<i64>, }
#[async_trait] pub trait ProductRepository: Send + Sync { // ... 표준 CRUD 메서드 /// 필터로 제품 쿼리. async fn query(&self, query: ProductQuery) -> Result<Vec<Product>, Self::Error>; }
SELECT id, name, description, price, category_id, created_at FROM products WHERE ($1 IS NULL OR name = $1) AND ($2 IS NULL OR description ILIKE $2) AND ($3 IS NULL OR price >= $3) AND ($4 IS NULL OR price <= $4) AND ($5 IS NULL OR category_id = $5) AND ($6 IS NULL OR created_at >= $6) AND ($7 IS NULL OR created_at <= $7) ORDER BY created_at DESC LIMIT $8 OFFSET $9
필드가 제공된 값과 같은 레코드를 필터링합니다.
#[filter] pub status: String, #[filter(eq)] // 위와 동일 pub category_id: Uuid,
생성됨:
pub status: Option<String>, pub category_id: Option<Uuid>,
SQL:
WHERE status = $1 AND category_id = $2
대소문자를 구분하지 않는 패턴 매칭(ILIKE)을 사용하여 필터링합니다.
#[filter(like)] pub name: String, #[filter(like)] pub description: String,
생성됨:
pub name: Option<String>, pub description: Option<String>,
SQL:
WHERE name ILIKE $1 AND description ILIKE $2
사용법:
와일드카드 없이 부분 문자열만 전달하세요. 생성된 코드가 값을 %...%로
감싸고 그 안의 %, _, \를 이스케이프하므로, 사용자가 입력한
와일드카드는 검색 범위를 넓히지 않고 문자 그대로 매칭됩니다:
let query = ProductQuery { name: Some("widget".into()), // "widget" 포함 description: Some("premium".into()), // "premium" 포함 ..Default::default() };
모든 like 필터는 부분 문자열 검색입니다. 접두사만 또는 접미사만
매칭하는 것은 이 필터로 표현할 수 없습니다.
범위 내에서 필터링합니다 (경계 포함).
#[filter(range)] pub price: i64, #[filter(range)] pub created_at: DateTime<Utc>,
생성됨:
pub price_from: Option<i64>, pub price_to: Option<i64>, pub created_at_from: Option<DateTime<Utc>>, pub created_at_to: Option<DateTime<Utc>>,
SQL:
WHERE price >= $1 AND price <= $2 AND created_at >= $3 AND created_at <= $4
// 카테고리별 제품 찾기 let query = ProductQuery { category_id: Some(electronics_category_id), ..Default::default() }; let products = repo.query(query).await?;
// 2페이지 가져오기 (페이지당 20개) let query = ProductQuery { limit: Some(20), offset: Some(20), ..Default::default() }; let products = repo.query(query).await?;
// 저렴한 전자제품 검색 let query = ProductQuery { category_id: Some(electronics_category_id), price_from: Some(0), price_to: Some(10000), // 100ドル.00 name: Some("phone".into()), limit: Some(50), ..Default::default() }; let products = repo.query(query).await?;
// 이번 달에 생성된 제품 가져오기 let now = Utc::now(); let month_start = now.with_day(1).unwrap().date_naive().and_hms_opt(0, 0, 0).unwrap(); let query = ProductQuery { created_at_from: Some(month_start.and_utc()), created_at_to: Some(now), ..Default::default() }; let products = repo.query(query).await?;
use axum::{extract::Query, Json}; #[derive(Deserialize)] pub struct ProductQueryParams { pub name: Option<String>, pub category_id: Option<Uuid>, pub min_price: Option<i64>, pub max_price: Option<i64>, pub page: Option<i64>, pub per_page: Option<i64>, } async fn list_products( Query(params): Query<ProductQueryParams>, pool: Extension<PgPool>, ) -> Result<Json<Vec<ProductResponse>>, AppError> { let page = params.page.unwrap_or(1); let per_page = params.per_page.unwrap_or(20).min(100); let query = ProductQuery { name: params.name, category_id: params.category_id, price_from: params.min_price, price_to: params.max_price, limit: Some(per_page), offset: Some((page - 1) * per_page), ..Default::default() }; let products = pool.query(query).await?; let responses: Vec<_> = products.into_iter().map(ProductResponse::from).collect(); Ok(Json(responses)) }
soft_delete가 활성화되면 쿼리가 자동으로 삭제된 레코드를 제외합니다:
#[derive(Entity)] #[entity(table = "documents", soft_delete)] pub struct Document { #[id] pub id: Uuid, #[field(create, response)] #[filter(like)] pub title: String, #[field(skip)] pub deleted_at: Option<DateTime<Utc>>, }
생성되는 SQL:
SELECT * FROM documents WHERE deleted_at IS NULL AND ($1 IS NULL OR title ILIKE $1) LIMIT $2 OFFSET $3
삭제된 항목 포함을 위한 추가 메서드:
async fn query_with_deleted(&self, query: DocumentQuery) -> Result<Vec<Document>, Self::Error>;
복잡한 쿼리의 경우 sql = "trait"를 사용하고 커스텀 필터링을 구현하세요:
#[derive(Entity)] #[entity(table = "products", sql = "trait")] pub struct Product { /* ... */ } pub trait ProductQueryExt { async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error>; async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error>; } #[async_trait] impl ProductQueryExt for PgPool { async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error> { let rows: Vec<ProductRow> = sqlx::query_as( r#" SELECT * FROM products WHERE to_tsvector('english', name || ' ' || description) @@ plainto_tsquery('english', 1ドル) ORDER BY ts_rank(to_tsvector('english', name || ' ' || description), plainto_tsquery('english', 1ドル)) DESC LIMIT 2ドル "# ) .bind(term) .bind(limit) .fetch_all(self) .await?; Ok(rows.into_iter().map(Product::from).collect()) } async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error> { let rows: Vec<ProductRow> = sqlx::query_as( "SELECT * FROM products WHERE tags && 1ドル" ) .bind(tags) .fetch_all(self) .await?; Ok(rows.into_iter().map(Product::from).collect()) } }
- 기본 페이지네이션 — 대량 결과 방지를 위해 항상 합리적인 제한 적용
- 패턴 검증 — SQL 문제 방지를 위해 LIKE 패턴 정화
- 필터 컬럼 인덱싱 — 자주 필터링되는 필드에 데이터베이스 인덱스 생성
- 구체적인 필터 사용 — 가능하면 패턴 일치보다 정확한 일치 선호
- 정렬과 결합 — 쿼리 구조체에 정렬 필드 추가 고려
정렬 가능한 컬럼에 #[sort]를 표시하세요. Query 구조체는 화이트리스트 셀렉터 {Entity}SortField(컬럼당 Asc/Desc 변형, JSON은 snake_case)를 얻어 사용자 입력이 SQL을 주입할 수 없습니다. 모든 리포지토리에는 list_after keyset 페이지네이션도 생성됩니다. 기본 UUIDv7 id에서는 id 순 순회가 시간순으로 안정적이며 OFFSET과 달리 깊은 페이지에서도 성능이 유지됩니다.
#[derive(Entity)] #[entity(table = "posts")] pub struct Post { #[id] pub id: Uuid, #[field(create, update, response)] #[sort] #[filter(like)] pub title: String, #[field(create, response)] #[sort] pub views: i64, } let query = PostQuery { sort: Some(PostSortField::ViewsDesc), limit: Some(20), ..Default::default() }; let top: Vec<Post> = pool.query(query).await?; let page: Vec<Post> = pool.list_after(None, 20).await?; let next: Vec<Post> = pool.list_after(page.last().map(|p| p.id), 20).await?;
텍스트 컬럼의 #[filter(search)]는 퍼지 부분 문자열 필터(col ILIKE '%' || $n || '%'; 검색어는 입력한 그대로 바인딩되므로 그 안의 %는 문자 그대로가 아니라 와일드카드로 동작합니다. #[filter(like)]와 다른 점입니다)를 추가합니다. migrations가 켜져 있으면 해당 gin_trgm_ops 인덱스가 MIGRATION_UP에 포함되고 pg_trgm이 MIGRATION_EXTENSIONS에 자동 추가됩니다. 컴파일 타임 검사: 필드는 String이어야 합니다.
#[field(create, update, response)] #[filter(search)] pub title: String, let hits = pool.query(ArticleQuery { title: Some("rust".into()), ..Default::default() }).await?;
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级