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
//! Integration test: explicit-ownership query builder over a real PG.
//!
//! Proves find/filter/order/paginate/eager-load/CRUD against a real
//! PostgreSQL, using the explicit `&Db` on every call. PG-gated: skipped
//! when `ARCATURE_TEST_DB_URL` is unset (CI runs it with PG).

#[path = "../common/mod.rs"]
mod common;

use common::post_entity;
use common::user_entity;
use common::{DbOrSkip, require_db, reset_rows, setup_schema};

use arcature_data::{Page, QueryModel, find_by_pk, insert, of};
use sea_orm::{ActiveValue, ColumnTrait};

#[tokio::test]
async fn query_filter_order_paginate_and_eager_load() {
    let DbOrSkip::Db(db) = require_db().await else {
        return;
    };
    setup_schema(db.db()).await.expect("schema");
    reset_rows(db.db()).await.expect("reset");

    // Seed: two users, posts distributed across them. `id` is auto-increment
    // so it is `Unset`; the DB populates it.
    let now = chrono::Utc::now().fixed_offset();
    let u1 = insert(
        db.db(),
        user_entity::ActiveModel {
            id: ActiveValue::NotSet,
            email: ActiveValue::Set("a@x.test".into()),
            name: ActiveValue::Set("Alice".into()),
            created_at: ActiveValue::Set(now),
        },
    )
    .await
    .expect("insert user 1");
    let u2 = insert(
        db.db(),
        user_entity::ActiveModel {
            id: ActiveValue::NotSet,
            email: ActiveValue::Set("b@x.test".into()),
            name: ActiveValue::Set("Bob".into()),
            created_at: ActiveValue::Set(now),
        },
    )
    .await
    .expect("insert user 2");

    for (user_id, title, active) in [
        (u1.id, "A1".to_string(), true),
        (u1.id, "A2".to_string(), true),
        (u1.id, "A3-draft".to_string(), false),
        (u2.id, "B1".to_string(), true),
        (u2.id, "B2".to_string(), true),
    ] {
        insert(
            db.db(),
            post_entity::ActiveModel {
                id: ActiveValue::NotSet,
                user_id: ActiveValue::Set(user_id),
                title: ActiveValue::Set(title),
                active: ActiveValue::Set(active),
                body: ActiveValue::Set(None),
                created_at: ActiveValue::Set(now),
            },
        )
        .await
        .expect("insert post");
    }

    // find_by_pk — explicit &Db, returns Option. Turbofish names the entity.
    let found = find_by_pk::<user_entity::Entity, _>(db.db(), u1.id)
        .await
        .expect("find_by_pk");
    assert!(found.is_some(), "user 1 should be found by PK");
    assert_eq!(found.unwrap().email, "a@x.test");

    let missing = find_by_pk::<user_entity::Entity, _>(db.db(), 999_999)
        .await
        .expect("find_by_pk missing");
    assert!(
        missing.is_none(),
        "missing PK returns Ok(None), not an error"
    );

    // filter + order_by_desc — all active posts, newest title first.
    let active = post_entity::Entity::query(db.db())
        .filter(post_entity::Column::Active.eq(true))
        .order_by_desc(post_entity::Column::Title)
        .all()
        .await
        .expect("query active");
    assert_eq!(active.len(), 4, "four active posts");
    assert_eq!(active[0].title, "B2");

    // paginate — 1 per page, page 2 of active posts.
    let page: Page<post_entity::Model> = post_entity::Entity::query(db.db())
        .filter(post_entity::Column::Active.eq(true))
        .paginate(1)
        .page(2)
        .fetch()
        .await
        .expect("paginate");
    assert_eq!(page.per_page, 1);
    assert_eq!(page.page, 2);
    assert_eq!(page.total, 4, "total across all pages");
    assert_eq!(page.num_pages, 4);
    assert_eq!(page.rows.len(), 1, "one row on page 2");

    // page 0 is a typed error (not a silent empty page).
    let err = post_entity::Entity::query(db.db())
        .paginate(2)
        .page(0)
        .fetch()
        .await
        .expect_err("page 0 must error");
    assert_eq!(err.id(), "arcature_data.pagination.page_must_be_positive");

    // per_page 0 is a typed error.
    let err = post_entity::Entity::query(db.db())
        .paginate(0)
        .fetch()
        .await
        .expect_err("per_page 0 must error");
    assert_eq!(
        err.id(),
        "arcature_data.pagination.per_page_must_be_positive"
    );

    // eager-load: posts with their user (find_also_related). `.with` is async
    // and returns the pairs directly.
    let with_users: Vec<(post_entity::Model, Option<user_entity::Model>)> =
        post_entity::Entity::query(db.db())
            .with(of::<user_entity::Entity>())
            .await
            .expect("eager load");
    assert!(
        with_users.iter().all(|(_, u)| u.is_some()),
        "each post has a user"
    );
    assert_eq!(with_users.len(), 5);

    db.stop().await;
}