rust-ef 2.2.0

Rust Entity Framework - An EFCore-inspired ORM for Rust
Documentation
//! Composable predicates: `Predicate<T>` + `QueryBuilder::where_`.
//!
//! Covers the EF Core `PredicateBuilder`-style workflow — seed an `AND`/`OR`
//! chain, combine `linq!(...)` fragments with `and` / `or`, then apply
//! the result to a query — at the SQL level (grouping and parameter order) and
//! against a real SQLite database.

use std::collections::HashSet;

use rust_ef::db_context::{DbContext, DbContextOptionsBuilder};
use rust_ef::error::EFError;
use rust_ef::prelude::*;
use rust_ef::provider::DbValue;
use rust_ef::query::{BoolExpr, FilterCondition, Predicate, QueryBuilder, QueryState};
use rust_ef_sqlite::DbContextOptionsBuilderExt as _;

#[derive(Debug, Clone, EntityType)]
#[table("predicate_products")]
struct Product {
    #[primary_key]
    id: i32,
    name: String,
    description: String,
    price: f64,
    is_deleted: bool,
}

// ---------------------------------------------------------------------------
// SQL shape (no database)
// ---------------------------------------------------------------------------

#[test]
fn always_seed_adds_no_sql() {
    let query =
        QueryBuilder::<Product>::new("predicate_products").filter(Predicate::<Product>::always());

    assert_eq!(query.to_sql(), "SELECT * FROM predicate_products");
    assert!(query.compile_sql().1.is_empty());
}

#[test]
fn or_group_keeps_its_parentheses_and_value_order() {
    let keyword = "rust";
    let predicate = Predicate::<Product>::always()
        .and(linq!(|p: Product| !p.is_deleted))
        .and(
            linq!(|p: Product| p.name.contains(keyword))
                .or(linq!(|p: Product| p.description.contains(keyword))),
        )
        .and(linq!(|p: Product| p.price >= 9.9));

    let (sql, params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(predicate)
        .compile_sql();

    assert!(
        sql.contains("(name LIKE ?) OR (description LIKE ?)"),
        "OR group must stay grouped: {sql}"
    );
    assert_eq!(
        params,
        vec![
            DbValue::Bool(true),
            DbValue::String("%rust%".into()),
            DbValue::String("%rust%".into()),
            DbValue::F64(9.9),
        ],
        "values must follow tree order"
    );
}

#[test]
fn never_seed_disappears_from_an_or_chain() {
    let predicate = Predicate::<Product>::never()
        .or(linq!(|p: Product| p.name.contains("a")))
        .or(linq!(|p: Product| p.description.contains("b")));

    let (sql, params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(predicate)
        .compile_sql();

    assert!(!sql.contains("1=0"), "seed must not leak into SQL: {sql}");
    assert!(
        sql.contains("(name LIKE ?) OR (description LIKE ?)"),
        "{sql}"
    );
    assert_eq!(params.len(), 2);
}

#[test]
fn never_applied_on_its_own_matches_nothing() {
    let (sql, params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(Predicate::<Product>::never())
        .compile_sql();

    assert!(sql.contains("1=0"), "{sql}");
    assert!(params.is_empty());
}

#[test]
fn where_appends_to_conditions_added_earlier() {
    let query = QueryBuilder::<Product>::new("predicate_products")
        .filter(linq!(|p: Product| p.price > 1.0))
        .filter(linq!(|p: Product| p.name.contains("x")));

    let (sql, params) = query.compile_sql();

    assert!(sql.contains("(price > ?) AND (name LIKE ?)"), "{sql}");
    assert_eq!(
        params,
        vec![DbValue::F64(1.0), DbValue::String("%x%".into())]
    );
}

#[test]
fn empty_in_list_matches_no_rows() {
    // `col IN ()` is not valid SQL; an empty candidate set must match nothing.
    let ids: Vec<i32> = Vec::new();
    let (sql, params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(linq!(|p: Product| ids.contains(p.id)))
        .compile_sql();

    assert_eq!(sql, "SELECT * FROM predicate_products WHERE 1 = 0");
    assert!(params.is_empty());
}

#[test]
fn predicate_form_yields_a_typed_predicate() {
    // `linq!(...)` is already a `Predicate<Product>`; `into_expr()`
    // exposes the encoded tree for APIs that take a bare `BoolExpr`.
    let predicate: Predicate<Product> = linq!(|p: Product| p.price > 0.0);

    let mut state = QueryState::new("predicate_products");
    state.where_expr = Some(predicate.into_expr());

    assert!(state.to_sql().contains("price > ?"));
}

#[test]
fn identity_predicates_are_reduced_away() {
    let base = linq!(|p: Product| p.price > 1.0);
    let with_always = Predicate::<Product>::always().and(base.clone());
    let with_never = Predicate::<Product>::never().or(base);

    let (and_sql, and_params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(with_always)
        .compile_sql();
    let (or_sql, or_params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(with_never)
        .compile_sql();

    assert_eq!(and_sql, or_sql);
    assert_eq!(and_sql, "SELECT * FROM predicate_products WHERE price > ?");
    assert_eq!(and_params, or_params);
    assert!(!and_sql.contains("1=1") && !and_sql.contains("1=0"));
}

#[test]
fn identity_flags_and_option_combinators() {
    assert!(Predicate::<Product>::always().is_always());
    assert!(Predicate::<Product>::never().is_never());
    assert!(Predicate::<Product>::always().not().is_never());
    assert!(Predicate::<Product>::never().not().is_always());

    // Optional conditions: `None` contributes no SQL, so handlers do not need
    // an `if let` around each optional filter.
    let none: Option<Predicate<Product>> = None;
    let predicate = Predicate::<Product>::always()
        .and_opt(none)
        .and_opt(Some(linq!(|p: Product| p.price > 1.0)))
        .or_opt(None::<Predicate<Product>>);

    let (sql, params) = QueryBuilder::<Product>::new("predicate_products")
        .filter(predicate)
        .compile_sql();
    assert_eq!(sql, "SELECT * FROM predicate_products WHERE price > ?");
    assert_eq!(params, vec![DbValue::F64(1.0)]);
}

#[test]
#[should_panic(expected = "missing bound values")]
fn hand_built_condition_without_values_is_rejected() {
    // `FilterCondition::new` declares a placeholder but carries no value; using
    // it as a self-contained predicate must fail fast instead of handing the
    // driver a `?` with nothing to bind.
    let expr = BoolExpr::Filter(FilterCondition::new("price", ">", 1));
    let _ = QueryBuilder::<Product>::new("predicate_products").filter(expr);
}

// ---------------------------------------------------------------------------
// Execution against SQLite
// ---------------------------------------------------------------------------

async fn make_ctx() -> DbContext {
    let mut builder = DbContextOptionsBuilder::new();
    builder.use_sqlite_in_memory();
    let options = builder.build();
    let mut ctx = DbContext::from_options(&options).expect("ctx");
    ctx.set::<Product>();
    ctx.ensure_created().await.expect("ensure_created");
    ctx
}

async fn seed(ctx: &mut DbContext) {
    let products = [
        (1, "rust book", "systems programming", 30.0, false),
        (2, "go book", "rust in action", 20.0, false),
        (3, "rust cheap", "nothing", 5.0, false),
        (4, "rust deleted", "rust everywhere", 40.0, true),
    ];
    for (id, name, description, price, is_deleted) in products {
        ctx.add::<Product>(Product {
            id,
            name: name.into(),
            description: description.into(),
            price,
            is_deleted,
        });
    }
    ctx.save_changes().await.expect("seed");
}

/// Mirrors the EF Core `PredicateBuilder` example: a soft-delete base filter,
/// an `OR` group over two columns, and an optional range condition.
fn product_filter(keyword: &str, min_price: Option<f64>) -> Predicate<Product> {
    let mut predicate = Predicate::<Product>::always()
        .and(linq!(|p: Product| !p.is_deleted))
        .and(
            linq!(|p: Product| p.name.contains(keyword))
                .or(linq!(|p: Product| p.description.contains(keyword))),
        );

    if let Some(min_price) = min_price {
        predicate = predicate.and(linq!(|p: Product| p.price >= min_price));
    }
    predicate
}

async fn matching_ids(predicate: Predicate<Product>) -> Vec<i32> {
    let mut ctx = make_ctx().await;
    seed(&mut ctx).await;
    let rows = ctx
        .set::<Product>()
        .query()
        .filter(predicate)
        .to_list()
        .await
        .expect("query");
    rows.into_iter().map(|p| p.id).collect()
}

#[tokio::test]
async fn keyword_or_group_matches_either_column() {
    let ids = matching_ids(product_filter("rust", None)).await;
    assert_eq!(ids, vec![1, 2, 3], "soft-deleted row 4 must stay filtered");
}

#[tokio::test]
async fn optional_range_condition_narrows_the_same_predicate() {
    let ids = matching_ids(product_filter("rust", Some(10.0))).await;
    assert_eq!(ids, vec![1, 2]);
}

#[tokio::test]
async fn count_and_page_share_one_predicate() {
    let mut ctx = make_ctx().await;
    seed(&mut ctx).await;

    let base = ctx
        .set::<Product>()
        .query()
        .filter(product_filter("rust", None));

    let total = base.clone().count().await.expect("count");
    let rows = base.to_list().await.expect("list");

    assert_eq!(total, 3);
    assert_eq!(rows.len() as i64, total);
    let ids: HashSet<i32> = rows.into_iter().map(|p| p.id).collect();
    assert_eq!(ids, HashSet::from([1, 2, 3]));
}

#[tokio::test]
async fn never_predicate_returns_no_rows() {
    let ids = matching_ids(Predicate::<Product>::never()).await;
    assert!(ids.is_empty());
}

#[test]
fn global_filter_infers_the_entity_from_the_predicate() {
    let mut builder = DbContextOptionsBuilder::new();
    builder.use_sqlite_in_memory();
    let options = builder.build();
    let mut ctx = DbContext::from_options(&options).expect("ctx");

    // No turbofish: `T` comes from the `Predicate<Product>`.
    ctx.model()
        .has_query_filter(linq!(|p: Product| !p.is_deleted));

    assert!(ctx
        .model()
        .get_query_filter(&std::any::TypeId::of::<Product>())
        .is_some());
}

#[tokio::test]
async fn empty_in_list_returns_no_rows() {
    let mut ctx = make_ctx().await;
    seed(&mut ctx).await;

    let ids: Vec<i32> = Vec::new();
    let rows = ctx
        .set::<Product>()
        .query()
        .filter(linq!(|p: Product| ids.contains(p.id)))
        .to_list()
        .await
        .expect("empty IN must produce valid SQL");

    assert!(rows.is_empty());
}

#[tokio::test]
async fn execute_update_without_set_columns_is_rejected() {
    let mut ctx = make_ctx().await;
    seed(&mut ctx).await;

    let err = ctx
        .set::<Product>()
        .query()
        .filter(product_filter("rust", None))
        .execute_update()
        .execute()
        .await
        .unwrap_err();

    assert!(
        matches!(err, EFError::Configuration(..)),
        "expected a configuration error, got {err:?}"
    );
}