rust-ef 1.5.3

Rust Entity Framework - An EFCore-inspired ORM for Rust
Documentation
//! Integration tests for v1.1 Lazy Loading.
//!
//! Covers:
//! - Lazy loading disabled by default (navigations empty, `is_loaded()` false)
//! - HasMany lazy load via `load().await`
//! - BelongsTo lazy load via `load().await`
//! - `is_loaded()` state transitions
//! - Double `load()` is a no-op
//! - Include takes precedence over lazy loading
//! - No lazy context → `load()` is a safe no-op

use rust_ef::db_context::{DbContext, DbContextOptionsBuilder};
use rust_ef::prelude::*;
use rust_ef_sqlite::DbContextOptionsBuilderExt as _;

// ---------------------------------------------------------------------------
// Entities — use #[derive(EntityType)] so ILazyInit is auto-generated.
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, EntityType)]
#[table("lazy_blogs")]
struct LazyBlog {
    #[primary_key]
    #[auto_increment]
    blog_id: i32,
    #[required]
    url: String,
    #[navigation]
    posts: HasMany<LazyPost>,
}

#[derive(Debug, Clone, EntityType)]
#[table("lazy_posts")]
struct LazyPost {
    #[primary_key]
    #[auto_increment]
    post_id: i32,
    #[required]
    title: String,
    #[foreign_key(LazyBlog)]
    blog_id: i32,
    #[navigation]
    blog: BelongsTo<LazyBlog>,
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn build_ctx(lazy: bool) -> DbContext {
    let mut builder = DbContextOptionsBuilder::new();
    builder.use_sqlite_in_memory();
    builder.use_lazy_loading(lazy);
    let options = builder.build();
    DbContext::from_options(&options).expect("DbContext")
}

async fn seed_data(ctx: &mut DbContext) {
    ctx.set::<LazyBlog>();
    ctx.set::<LazyPost>();
    ctx.ensure_created().await.unwrap();

    ctx.set::<LazyBlog>().add(LazyBlog {
        blog_id: 0,
        url: "https://lazy.example".into(),
        posts: HasMany::new(),
    });
    ctx.save_changes().await.unwrap();

    let blog_id = ctx.set::<LazyBlog>().query().to_list().await.unwrap()[0].blog_id;

    ctx.set::<LazyPost>().add(LazyPost {
        post_id: 0,
        title: "Post A".into(),
        blog_id,
        blog: BelongsTo::new(),
    });
    ctx.set::<LazyPost>().add(LazyPost {
        post_id: 0,
        title: "Post B".into(),
        blog_id,
        blog: BelongsTo::new(),
    });
    ctx.save_changes().await.unwrap();
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[tokio::test]
async fn test_lazy_loading_disabled_by_default() {
    let mut ctx = build_ctx(false);
    seed_data(&mut ctx).await;

    let blogs = ctx.set::<LazyBlog>().query().to_list().await.unwrap();
    assert_eq!(blogs.len(), 1);

    // No includes → navigation is empty and not loaded.
    assert!(!blogs[0].posts.is_loaded());
    assert_eq!(blogs[0].posts.len(), 0);
}

#[tokio::test]
async fn test_lazy_loading_disabled_load_is_noop() {
    let mut ctx = build_ctx(false);
    seed_data(&mut ctx).await;

    let mut blogs = ctx.set::<LazyBlog>().query().to_list().await.unwrap();
    assert!(!blogs[0].posts.is_loaded());

    // Without a LazyContext, load() is a safe no-op.
    blogs[0].posts.load().await.unwrap();
    assert!(!blogs[0].posts.is_loaded());
    assert_eq!(blogs[0].posts.len(), 0);
}

#[tokio::test]
async fn test_hasmany_lazy_load() {
    let mut ctx = build_ctx(true);
    seed_data(&mut ctx).await;

    let mut blogs = ctx.set::<LazyBlog>().query().to_list().await.unwrap();
    assert_eq!(blogs.len(), 1);

    // Lazy loading enabled but not yet triggered.
    assert!(!blogs[0].posts.is_loaded());
    assert_eq!(blogs[0].posts.len(), 0);

    // Trigger lazy load.
    blogs[0].posts.load().await.unwrap();
    assert!(blogs[0].posts.is_loaded());
    assert_eq!(blogs[0].posts.len(), 2);

    let titles: Vec<&str> = blogs[0]
        .posts
        .items()
        .iter()
        .map(|p| p.title.as_str())
        .collect();
    assert!(titles.contains(&"Post A"));
    assert!(titles.contains(&"Post B"));
}

#[tokio::test]
async fn test_belongs_to_lazy_load() {
    let mut ctx = build_ctx(true);
    seed_data(&mut ctx).await;

    let mut posts = ctx.set::<LazyPost>().query().to_list().await.unwrap();
    assert_eq!(posts.len(), 2);

    // BelongsTo not loaded yet.
    assert!(!posts[0].blog.is_loaded());
    assert!(posts[0].blog.get().is_none());

    // Trigger lazy load.
    posts[0].blog.load().await.unwrap();
    assert!(posts[0].blog.is_loaded());
    let blog = posts[0].blog.get().expect("blog loaded");
    assert_eq!(blog.url, "https://lazy.example");
}

#[tokio::test]
async fn test_double_load_is_noop() {
    let mut ctx = build_ctx(true);
    seed_data(&mut ctx).await;

    let mut blogs = ctx.set::<LazyBlog>().query().to_list().await.unwrap();

    blogs[0].posts.load().await.unwrap();
    let count_after_first = blogs[0].posts.len();
    assert!(blogs[0].posts.is_loaded());

    // Second load should be a no-op.
    blogs[0].posts.load().await.unwrap();
    assert!(blogs[0].posts.is_loaded());
    assert_eq!(blogs[0].posts.len(), count_after_first);
}

#[tokio::test]
async fn test_include_takes_precedence_over_lazy() {
    let mut ctx = build_ctx(true);
    seed_data(&mut ctx).await;

    // When includes are specified, eager loading runs and lazy contexts
    // are NOT attached (the `to_list` path skips lazy attach when
    // includes are non-empty).
    let blogs = linq!(ctx.set::<LazyBlog>(); include b.posts)
        .to_list()
        .await
        .unwrap();

    assert_eq!(blogs.len(), 1);
    // Eagerly loaded → is_loaded() is true, items present.
    assert!(blogs[0].posts.is_loaded());
    assert_eq!(blogs[0].posts.len(), 2);
}

#[tokio::test]
async fn test_nested_lazy_load_attaches_child_contexts() {
    let mut ctx = build_ctx(true);
    seed_data(&mut ctx).await;

    let mut blogs = ctx.set::<LazyBlog>().query().to_list().await.unwrap();

    // Load posts lazily — each post should get a lazy context for its
    // BelongsTo<LazyBlog> navigation (nested lazy loading).
    blogs[0].posts.load().await.unwrap();
    let posts = blogs[0].posts.items_mut();
    for post in posts.iter_mut() {
        // The child's BelongsTo should have a lazy context attached
        // (depth incremented). We don't call load() here to avoid
        // infinite recursion (Blog ↔ Post), but is_loaded() should
        // be false, confirming the context is attached but not yet
        // triggered.
        assert!(!post.blog.is_loaded());
    }
}