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

Определение связей между сущностями с помощью #[belongs_to] и #[has_many]. Связи генерируют типобезопасные методы навигации в репозиториях.

Быстрый старт

// Родительская сущность
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,
}

// Дочерняя сущность
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    pub user_id: Uuid,

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

Генерируемый код

Для #[has_many(Post)] на User

#[async_trait]
impl UserRepository for PgPool {
    // ... стандартные CRUD-методы

    /// Найти все посты, принадлежащие этому пользователю.
    async fn find_posts(&self, user_id: Uuid) -> Result<Vec<Post>, Self::Error> {
        let rows: Vec<PostRow> = sqlx::query_as(
            "SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC"
        )
        .bind(&user_id)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Post::from).collect())
    }
}

Для #[belongs_to(User)] на Post

#[async_trait]
impl PostRepository for PgPool {
    // ... стандартные CRUD-методы

    /// Найти пользователя, которому принадлежит этот пост.
    async fn find_user(&self, id: Uuid) -> Result<Option<User>, Self::Error> {
        // Сначала получаем пост для нахождения user_id
        let post = self.find_by_id(id).await?;

        if let Some(post) = post {
            let row: Option<UserRow> = sqlx::query_as(
                "SELECT * FROM users WHERE id = $1"
            )
            .bind(&post.user_id)
            .fetch_optional(self)
            .await?;

            Ok(row.map(User::from))
        } else {
            Ok(None)
        }
    }
}

Типы связей

belongs_to (Многие-к-одному)

Дочерняя сущность ссылается на родительскую через внешний ключ.

#[derive(Entity)]
#[entity(table = "comments")]
pub struct Comment {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(Post)]
    pub post_id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    pub author_id: Uuid,

    #[field(create, response)]
    pub content: String,
}

Генерируемые методы:

  • find_post(comment_id)Option<Post>
  • find_author(comment_id)Option<User> (примечание: имя метода выводится из имени поля без _id)

has_many (Один-ко-многим)

Родительская сущность имеет множество дочерних.

#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
#[has_many(Comment)]
pub struct User {
    #[id]
    pub id: Uuid,
    // ...
}

Генерируемые методы:

  • find_posts(user_id)Vec<Post>
  • find_comments(user_id)Vec<Comment>

Примеры использования

Загрузка связанных данных

// Получение пользователя с его постами
async fn get_user_with_posts(
    pool: &PgPool,
    user_id: Uuid,
) -> Result<Option<(User, Vec<Post>)>, sqlx::Error> {
    let user = pool.find_by_id(user_id).await?;

    if let Some(user) = user {
        let posts = pool.find_posts(user_id).await?;
        Ok(Some((user, posts)))
    } else {
        Ok(None)
    }
}

// Получение поста с автором
async fn get_post_with_author(
    pool: &PgPool,
    post_id: Uuid,
) -> Result<Option<(Post, User)>, sqlx::Error> {
    let post = pool.find_by_id(post_id).await?;

    if let Some(post) = post {
        let user = pool.find_user(post.id).await?;
        if let Some(user) = user {
            return Ok(Some((post, user)));
        }
    }

    Ok(None)
}

Построение Response DTO

#[derive(Serialize)]
pub struct PostWithAuthor {
    #[serde(flatten)]
    pub post: PostResponse,
    pub author: UserResponse,
}

async fn get_posts_with_authors(
    pool: &PgPool,
    limit: i64,
) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
    let posts = pool.list(limit, 0).await?;

    let mut results = Vec::with_capacity(posts.len());

    for post in posts {
        if let Some(user) = pool.find_user(post.id).await? {
            results.push(PostWithAuthor {
                post: PostResponse::from(&post),
                author: UserResponse::from(&user),
            });
        }
    }

    Ok(results)
}

Вложенная загрузка с API

use axum::{extract::Path, Json};

#[derive(Serialize)]
pub struct UserProfile {
    pub user: UserResponse,
    pub posts: Vec<PostResponse>,
    pub post_count: usize,
}

async fn get_user_profile(
    Path(user_id): Path<Uuid>,
    pool: Extension<PgPool>,
) -> Result<Json<UserProfile>, AppError> {
    let user = pool.find_by_id(user_id).await?
        .ok_or(AppError::NotFound)?;

    let posts = pool.find_posts(user_id).await?;

    Ok(Json(UserProfile {
        user: UserResponse::from(&user),
        post_count: posts.len(),
        posts: posts.into_iter().map(PostResponse::from).collect(),
    }))
}

Множественные связи

Сущность может иметь несколько связей:

#[derive(Entity)]
#[entity(table = "organizations")]
#[has_many(User)]
#[has_many(Project)]
#[has_many(Team)]
pub struct Organization {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,
}

#[derive(Entity)]
#[entity(table = "projects")]
pub struct Project {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(Organization)]
    pub organization_id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    pub owner_id: Uuid,

    #[field(create, update, response)]
    pub name: String,
}

Генерируется для Organization:

  • find_users(org_id)
  • find_projects(org_id)
  • find_teams(org_id)

Генерируется для Project:

  • find_organization(project_id)
  • find_owner(project_id)

Пользовательские JOIN с sql = “trait”

Для сложных запросов с жадной загрузкой используйте пользовательский SQL:

#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post { /* ... */ }

pub struct PostWithRelations {
    pub post: Post,
    pub author: User,
    pub comments: Vec<Comment>,
}

pub trait PostRepositoryExt {
    async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error>;
    async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error>;
}

#[async_trait]
impl PostRepositoryExt for PgPool {
    async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error> {
        // Один запрос с JOIN
        let row = sqlx::query_as::<_, (PostRow, UserRow)>(
            r#"
            SELECT p.*, u.*
            FROM posts p
            JOIN users u ON u.id = p.user_id
            WHERE p.id = $1
            "#
        )
        .bind(&id)
        .fetch_optional(self)
        .await?;

        if let Some((post_row, user_row)) = row {
            let comments: Vec<CommentRow> = sqlx::query_as(
                "SELECT * FROM comments WHERE post_id = $1 ORDER BY created_at"
            )
            .bind(&id)
            .fetch_all(self)
            .await?;

            Ok(Some(PostWithRelations {
                post: Post::from(post_row),
                author: User::from(user_row),
                comments: comments.into_iter().map(Comment::from).collect(),
            }))
        } else {
            Ok(None)
        }
    }

    async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error> {
        let rows = sqlx::query_as::<_, (PostRow, UserRow)>(
            r#"
            SELECT p.*, u.*
            FROM posts p
            JOIN users u ON u.id = p.user_id
            ORDER BY p.created_at DESC
            LIMIT $1
            "#
        )
        .bind(limit)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter()
            .map(|(p, u)| (Post::from(p), User::from(u)))
            .collect())
    }
}

С фильтрацией

Комбинируйте связи с фильтрацией запросов:

#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    #[filter]  // Включить фильтрацию по user_id
    pub user_id: Uuid,

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

    #[field(response)]
    #[auto]
    #[filter(range)]
    pub created_at: DateTime<Utc>,
}

Использование:

// Получение постов конкретного пользователя с фильтром по заголовку
let query = PostQuery {
    user_id: Some(user_id),
    title: Some("%rust%".into()),
    limit: Some(20),
    ..Default::default()
};
let posts = pool.query(query).await?;

Лучшие практики

  1. Избегайте N+1 запросов — Используйте JOIN для жадной загрузки нескольких связанных сущностей
  2. Используйте пагинацию — Всегда ограничивайте результаты has_many
  3. Учитывайте паттерны доступа — Добавляйте индексы на колонки внешних ключей
  4. Кэшируйте при необходимости — Кэшируйте часто запрашиваемые связанные данные
  5. Используйте проекции — Запрашивайте только нужные поля для связанных сущностей

См. также

  • [[Фильтрация|Фильтрация]] — Фильтрация запросов
  • Кастомный SQL — Сложные JOIN и запросы
  • [[Лучшие-практики|Лучшие практики]] — Советы по производительности

has_many through (многие-ко-многим)

Укажите junction-таблицу через through — репозиторий получит JOIN-выборку и управление связями; migrations генерирует MIGRATION_JUNCTIONS (композитный первичный ключ по обоим внешним ключам, ON DELETE CASCADE с обеих сторон).

#[derive(Entity)]
#[entity(table = "teams", migrations)]
#[has_many(User, through = "team_members")]
pub struct Team { /* ... */ }

for ddl in Team::MIGRATION_JUNCTIONS {
    sqlx::query(ddl).execute(&pool).await?;
}

pool.add_user(team_id, user_id).await?;
let members: Vec<User> = pool.find_users(team_id).await?;
let linked = pool.has_user(team_id, user_id).await?;
let removed = pool.remove_user(team_id, user_id).await?;

Генерируемые методы: find_users (INNER JOIN), add_user (идемпотентно, ON CONFLICT DO NOTHING), remove_user (false, если связи не было), has_user (SELECT EXISTS).