arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! CRUD operations over SeaORM `ActiveModel`s, with explicit `&Db` ownership.
//!
//! Each function takes the `&Db` handle explicitly (AGENTS.md §20; PROGRAM.md
//! AP2.1-6). They delegate to SeaORM's `ActiveModelTrait` (`insert` / `update` /
//! `delete`) over `db.orm()` — Arcature does not reimplement the mutation
//! engine. There is no hidden global pool and no automatic transaction
//! wrapping; a caller that needs atomicity wraps these in [`crate::Transaction`].

use arcature_db::Db;
use arcature_db::sea_orm;
use arcature_db::sea_orm::{ActiveModelTrait, EntityTrait};

use crate::error::DataError;

/// The row model of an `ActiveModel`'s entity. `A::Entity` is the SeaORM
/// `Entity`; its `Model` is the row type returned by insert/update/delete.
type Row<A> = <<A as sea_orm::ActiveModelTrait>::Entity as EntityTrait>::Model;

/// Insert a new row from an `ActiveModel`. Returns the inserted model with
/// database-generated fields (auto-increment PKs, defaults) populated.
///
/// # Errors
///
/// Returns [`DataError::Database`] if the insert fails (e.g. a uniqueness
/// constraint violation surfaces as a SeaORM `DbErr`).
pub async fn insert<A>(db: &Db, active: A) -> Result<Row<A>, DataError>
where
    A: ActiveModelTrait + sea_orm::ActiveModelBehavior + Send,
    <A::Entity as EntityTrait>::Model: sea_orm::IntoActiveModel<A>,
{
    active.insert(db.orm()).await.map_err(DataError::from)
}

/// Update an existing row from an `ActiveModel`. Returns the updated model.
///
/// # Errors
///
/// Returns [`DataError::Database`] if the update fails.
pub async fn update<A>(db: &Db, active: A) -> Result<Row<A>, DataError>
where
    A: ActiveModelTrait + sea_orm::ActiveModelBehavior + Send,
    <A::Entity as EntityTrait>::Model: sea_orm::IntoActiveModel<A>,
{
    active.update(db.orm()).await.map_err(DataError::from)
}

/// Delete an existing row from its `ActiveModel`. Returns a
/// [`sea_orm::DeleteResult`] carrying the number of affected rows.
///
/// # Errors
///
/// Returns [`DataError::Database`] if the delete fails.
pub async fn delete<A>(db: &Db, active: A) -> Result<sea_orm::DeleteResult, DataError>
where
    A: ActiveModelTrait + sea_orm::ActiveModelBehavior + Send,
    <A::Entity as EntityTrait>::Model: sea_orm::IntoActiveModel<A>,
{
    active.delete(db.orm()).await.map_err(DataError::from)
}

/// Find a single row by its primary key.
///
/// This delegates to SeaORM's `Entity::find_by_id` over the explicit `&Db`.
/// It is the typed primary-key lookup path; for a route-key lookup the
/// application composes this with the `arcature` facade's `RouteModel`
/// binding (the facade owns `RouteModel`; this crate does not depend on it).
///
/// # Errors
///
/// Returns [`DataError::Database`] if the underlying query fails. A missing
/// row is `Ok(None)`, not an error — the caller decides whether that is a
/// 404 (PROGRAM.md AP2.1-6: "No `Post::find(id).await?` that requires a hidden
/// global"; this variant is explicit-ownership and returns `Option`).
pub async fn find_by_pk<E, P>(db: &Db, pk: P) -> Result<Option<E::Model>, DataError>
where
    E: sea_orm::EntityTrait,
    P: Into<<E::PrimaryKey as sea_orm::PrimaryKeyTrait>::ValueType> + Send,
{
    E::find_by_id(pk)
        .one(db.orm())
        .await
        .map_err(DataError::from)
}