Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

엔티티 필터링을 위한 타입 안전 쿼리 구조체를 생성합니다. 필터링은 컴파일 타임 안전성으로 페이지네이션, 검색, 범위 쿼리를 가능하게 합니다.

빠른 시작

#[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>;
}

생성되는 SQL

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] 또는 #[filter(eq)])

필드가 제공된 값과 같은 레코드를 필터링합니다.

#[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

패턴 일치 (#[filter(like)])

대소문자를 구분하지 않는 패턴 매칭(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()
};

범위 필터 (#[filter(range)])

범위 내에서 필터링합니다 (경계 포함).

#[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?;

API 엔드포인트 통합

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.map(|n| format!("%{}%", n)),
        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())
    }
}

모범 사례

  1. 기본 페이지네이션 — 대량 결과 방지를 위해 항상 합리적인 제한 적용
  2. 패턴 검증 — SQL 문제 방지를 위해 LIKE 패턴 정화
  3. 필터 컬럼 인덱싱 — 자주 필터링되는 필드에 데이터베이스 인덱스 생성
  4. 구체적인 필터 사용 — 가능하면 패턴 일치보다 정확한 일치 선호
  5. 정렬과 결합 — 쿼리 구조체에 정렬 필드 추가 고려

참고

  • [[속성|속성]] — 전체 속성 참조
  • [[커스텀-SQL|커스텀 SQL]] — 복잡한 커스텀 쿼리
  • [[관계|관계]] — 관계를 사용한 필터링

정렬과 keyset 페이지네이션

정렬 가능한 컬럼에 #[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 || '%', 검색어는 바인딩됨)를 추가합니다. migrations가 켜져 있으면 해당 gin_trgm_ops 인덱스가 MIGRATION_UP에 포함되고 pg_trgmMIGRATION_EXTENSIONS에 자동 추가됩니다. 컴파일 타임 검사: 필드는 String이어야 합니다.

#[field(create, update, response)]
#[filter(search)]
pub title: String,

let hits = pool.query(ArticleQuery { title: Some("rust".into()), ..Default::default() }).await?;