rust-ef 1.5.2

Rust Entity Framework - An EFCore-inspired ORM for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Tests for `linq!` Form B (multi-clause query) and Form C (value-producing).
//!
//! Covers clauses not already exercised by `linq_tests.rs` / `navigation_tests.rs` /
//! `sqlite_crud_tests.rs`: `order_by`, `group_by`, `select`, `having`, `min`/`max`
//! typed return (G1), `count`, `distinct`, `set`+`execute_update`, `take`/`skip`,
//! `inner_join`/`left_join`, and Form C `filter`/`index`/`key`.

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

#[derive(Debug, Clone, EntityType)]
#[table("dsl_blogs")]
struct DslBlog {
    #[primary_key]
    #[auto_increment]
    blog_id: i32,
    #[required]
    title: String,
    rating: i32,
    views: i64,
    published: bool,
    category: String,
    #[navigation]
    posts: HasMany<DslPost>,
}

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

fn make_ctx() -> DbContext {
    let mut builder = DbContextOptionsBuilder::new();
    builder.use_sqlite_in_memory();
    let options = builder.build();
    DbContext::from_options(&options).unwrap()
}

/// Seeds three blogs across two categories with posts attached.
/// Returns the DbContext ready for querying.
async fn seed() -> DbContext {
    let mut ctx = make_ctx();
    ctx.set::<DslBlog>();
    ctx.set::<DslPost>();
    ctx.ensure_created().await.unwrap();

    ctx.set::<DslBlog>().add(DslBlog {
        blog_id: 0,
        title: "Rust".into(),
        rating: 9,
        views: 100,
        published: true,
        category: "tech".into(),
        posts: HasMany::new(),
    });
    ctx.set::<DslBlog>().add(DslBlog {
        blog_id: 0,
        title: "Async".into(),
        rating: 7,
        views: 50,
        published: true,
        category: "tech".into(),
        posts: HasMany::new(),
    });
    ctx.set::<DslBlog>().add(DslBlog {
        blog_id: 0,
        title: "Cooking".into(),
        rating: 3,
        views: 10,
        published: false,
        category: "food".into(),
        posts: HasMany::new(),
    });
    ctx.save_changes().await.unwrap();

    let blogs = ctx.set::<DslBlog>().query().to_list().await.unwrap();
    let tech_id = blogs[0].blog_id;
    ctx.set::<DslPost>().add(DslPost {
        post_id: 0,
        title: "P1".into(),
        blog_id: tech_id,
        blog: BelongsTo::new(),
    });
    ctx.set::<DslPost>().add(DslPost {
        post_id: 0,
        title: "P2".into(),
        blog_id: tech_id,
        blog: BelongsTo::new(),
    });
    ctx.save_changes().await.unwrap();
    ctx
}

#[tokio::test]
async fn test_order_by_clause_desc() {
    let mut ctx = seed().await;
    let blogs = linq!(ctx.set::<DslBlog>(); order_by b.rating desc)
        .to_list()
        .await
        .unwrap();
    assert_eq!(blogs.len(), 3);
    assert_eq!(blogs[0].rating, 9);
    assert_eq!(blogs[2].rating, 3);
}

#[tokio::test]
async fn test_order_by_clause_asc() {
    let mut ctx = seed().await;
    let blogs = linq!(ctx.set::<DslBlog>(); order_by b.rating asc)
        .to_list()
        .await
        .unwrap();
    assert_eq!(blogs[0].rating, 3);
    assert_eq!(blogs[2].rating, 9);
}

#[tokio::test]
async fn test_take_skip_clauses() {
    let mut ctx = seed().await;
    let blogs = linq!(ctx.set::<DslBlog>(); order_by b.blog_id asc; skip 1; take 1)
        .to_list()
        .await
        .unwrap();
    assert_eq!(blogs.len(), 1);
    assert_eq!(blogs[0].title, "Async");
}

#[tokio::test]
async fn test_min_typed_return_i32() {
    // G1 verification: min returns typed Option<V>, not Option<String>.
    let mut ctx = seed().await;
    let min_rating: i32 = linq!(ctx.set::<DslBlog>(); min b.rating)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(min_rating, 3);
}

#[tokio::test]
async fn test_max_typed_return_i32() {
    // G1 verification: max returns typed Option<V>, not Option<String>.
    let mut ctx = seed().await;
    let max_rating: i32 = linq!(ctx.set::<DslBlog>(); max b.rating)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(max_rating, 9);
}

#[tokio::test]
async fn test_max_typed_return_i64() {
    // G1: cross-type inference — views column is i64.
    let mut ctx = seed().await;
    let max_views: i64 = linq!(ctx.set::<DslBlog>(); max b.views)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(max_views, 100);
}

#[tokio::test]
async fn test_min_empty_returns_none() {
    // G1: empty result set yields Ok(None), not an error.
    let mut ctx = make_ctx();
    ctx.set::<DslBlog>();
    ctx.ensure_created().await.unwrap();
    let min_rating: Option<i32> = linq!(ctx.set::<DslBlog>(); min b.rating).await.unwrap();
    assert!(min_rating.is_none());
}

#[tokio::test]
async fn test_count_clause() {
    let mut ctx = seed().await;
    let n: i64 = linq!(ctx.set::<DslBlog>(); count).await.unwrap();
    assert_eq!(n, 3);
}

#[tokio::test]
async fn test_distinct_clause() {
    let mut ctx = seed().await;
    // Distinct on the whole row; with a filter so the result is predictable.
    let rows = linq!(ctx.set::<DslBlog>(), |b: DslBlog| b.published; distinct)
        .to_list()
        .await
        .unwrap();
    assert_eq!(rows.len(), 2);
}

#[tokio::test]
async fn test_group_by_with_having() {
    let mut ctx = seed().await;
    // tech category has 2 blogs; food has 1. HAVING count > 1 keeps only tech.
    let blogs = linq!(ctx.set::<DslBlog>(); group_by b.category; having count(b.blog_id) > 1)
        .to_list()
        .await
        .unwrap();
    assert!(!blogs.is_empty());
}

#[tokio::test]
async fn test_having_with_and() {
    let mut ctx = seed().await;
    // tech: count=2 > 1 AND sum(views)=150 > 100 → true
    // food: count=1 > 1 → false (short-circuit) → excluded
    let blogs = linq!(
        ctx.set::<DslBlog>();
        group_by b.category;
        having count(b.blog_id) > 1 && sum(b.views) > 100
    )
    .to_list()
    .await
    .unwrap();
    assert_eq!(blogs.len(), 1, "only tech group should pass AND condition");
    assert_eq!(blogs[0].category, "tech");
}

#[tokio::test]
async fn test_having_with_or() {
    let mut ctx = seed().await;
    // tech: count=2 > 5 → false; sum(views)=150 > 100 → true → true
    // food: count=1 > 5 → false; sum(views)=10 > 100 → false → false
    let blogs = linq!(
        ctx.set::<DslBlog>();
        group_by b.category;
        having count(b.blog_id) > 5 || sum(b.views) > 100
    )
    .to_list()
    .await
    .unwrap();
    assert_eq!(blogs.len(), 1, "only tech group should pass OR condition");
    assert_eq!(blogs[0].category, "tech");
}

#[tokio::test]
async fn test_having_with_not() {
    let mut ctx = seed().await;
    // NOT(count > 1): tech (NOT true = false), food (NOT false = true)
    let blogs = linq!(
        ctx.set::<DslBlog>();
        group_by b.category;
        having !(count(b.blog_id) > 1)
    )
    .to_list()
    .await
    .unwrap();
    assert_eq!(blogs.len(), 1, "only food group should pass NOT condition");
    assert_eq!(blogs[0].category, "food");
}

#[tokio::test]
async fn test_having_compare_agg() {
    let mut ctx = seed().await;
    // sum(views) > count(blog_id):
    //   tech: 150 > 2 → true
    //   food: 10 > 1 → true
    let blogs = linq!(
        ctx.set::<DslBlog>();
        group_by b.category;
        having sum(b.views) > count(b.blog_id)
    )
    .to_list()
    .await
    .unwrap();
    assert_eq!(
        blogs.len(),
        2,
        "both groups should pass agg-vs-agg comparison"
    );
}

#[tokio::test]
async fn test_having_nested_and_or() {
    let mut ctx = seed().await;
    // count > 1 && (sum(views) > 100 || count(blog_id) > 0)
    //   tech: true && (true || true) → true
    //   food: false && ... → false
    let blogs = linq!(
        ctx.set::<DslBlog>();
        group_by b.category;
        having count(b.blog_id) > 1 && (sum(b.views) > 100 || count(b.blog_id) > 0)
    )
    .to_list()
    .await
    .unwrap();
    assert_eq!(blogs.len(), 1, "only tech should pass nested AND/OR");
    assert_eq!(blogs[0].category, "tech");
}

#[tokio::test]
async fn test_select_clause_returns_raw_rows() {
    let mut ctx = seed().await;
    let rows: Vec<Vec<DbValue>> =
        linq!(ctx.set::<DslBlog>(), |b: DslBlog| b.published; select (b.blog_id, b.title))
            .to_list()
            .await
            .unwrap();
    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0].len(), 2);
}

#[tokio::test]
async fn test_set_execute_update() {
    let mut ctx = seed().await;
    let affected = linq!(ctx.set::<DslBlog>(), |b: DslBlog| b.rating < 5; set b.published, false; execute_update)
        .await
        .unwrap();
    assert_eq!(affected, 1);

    // Verify the update took effect.
    let cooking = linq!(ctx.set::<DslBlog>(), |b: DslBlog| b.title == "Cooking")
        .to_list()
        .await
        .unwrap();
    assert_eq!(cooking.len(), 1);
    assert!(!cooking[0].published);
}

#[tokio::test]
async fn test_inner_join_clause() {
    let mut ctx = seed().await;
    let blogs =
        linq!(ctx.set::<DslBlog>(); inner_join |a: DslBlog, b: DslPost| a.blog_id == b.blog_id)
            .to_list()
            .await
            .unwrap();
    // Two posts belong to the first blog; inner join yields rows for those.
    assert!(!blogs.is_empty());
}

#[tokio::test]
async fn test_left_join_clause() {
    let mut ctx = seed().await;
    let blogs =
        linq!(ctx.set::<DslBlog>(); left_join |a: DslBlog, b: DslPost| a.blog_id == b.blog_id)
            .to_list()
            .await
            .unwrap();
    // Left join keeps all blogs; blog 1 appears twice (one row per matching post),
    // blogs 2 and 3 appear once each with NULL post side → 4 rows total.
    assert!(blogs.len() >= 3);
}

#[tokio::test]
async fn test_right_join_clause() {
    let mut ctx = seed().await;
    // RIGHT JOIN keeps all posts (right side), even if blog missing.
    // Both posts belong to blog 1, so result has 2 rows.
    let rows =
        linq!(ctx.set::<DslBlog>(); right_join |a: DslBlog, b: DslPost| a.blog_id == b.blog_id)
            .to_list()
            .await
            .unwrap();
    assert_eq!(rows.len(), 2, "right join should yield one row per post");
}

#[tokio::test]
async fn test_full_join_clause() {
    let mut ctx = seed().await;
    // FULL JOIN keeps all blogs and all posts. Blog 1 matches 2 posts,
    // blogs 2 and 3 have no matching posts → 4 rows total.
    let rows =
        linq!(ctx.set::<DslBlog>(); full_join |a: DslBlog, b: DslPost| a.blog_id == b.blog_id)
            .to_list()
            .await
            .unwrap();
    assert_eq!(rows.len(), 4, "full join should yield blogs + posts");
}

#[tokio::test]
async fn test_cross_join_clause() {
    let mut ctx = seed().await;
    // CROSS JOIN is the cartesian product: 3 blogs × 2 posts = 6 rows.
    let rows = linq!(ctx.set::<DslBlog>(); cross_join b: DslPost)
        .to_list()
        .await
        .unwrap();
    assert_eq!(rows.len(), 6, "cross join should yield cartesian product");
}

#[tokio::test]
async fn test_union_clause() {
    let mut ctx = seed().await;
    // Main: all blogs (3 rows). Operand: all blogs (3 rows, identical).
    // UNION dedupes identical rows → 3 rows.
    let op = ctx.set::<DslBlog>().query().compile_sql();
    let rows = linq!(ctx.set::<DslBlog>(); union op)
        .to_list()
        .await
        .unwrap();
    assert_eq!(rows.len(), 3, "UNION should dedupe identical rows");
}

#[tokio::test]
async fn test_union_all_clause() {
    let mut ctx = seed().await;
    // Main: all blogs (3 rows). Operand: all blogs (3 rows, identical).
    // UNION ALL does not dedupe → 6 rows.
    let op = ctx.set::<DslBlog>().query().compile_sql();
    let rows = linq!(ctx.set::<DslBlog>(); union_all op)
        .to_list()
        .await
        .unwrap();
    assert_eq!(rows.len(), 6, "UNION ALL should not dedupe");
}

#[tokio::test]
async fn test_except_clause() {
    let mut ctx = seed().await;
    // Main: all blogs (3 rows). EXCEPT operand: tech blogs (2 rows).
    // Result: 1 row (Cooking, the food blog).
    let op = linq!(ctx.set::<DslBlog>(), |b: DslBlog| b.category == "tech").compile_sql();
    let rows = linq!(ctx.set::<DslBlog>(); except op)
        .to_list()
        .await
        .unwrap();
    assert_eq!(rows.len(), 1, "EXCEPT should yield the difference");
    assert_eq!(rows[0].category, "food");
}

#[tokio::test]
async fn test_form_c_filter_produces_bool_expr() {
    use rust_ef::query::BoolExpr;
    let expr: BoolExpr = linq!(filter |b: DslBlog| b.published);
    // The expression is a value, not a method chain; just verify it's not Filter-free.
    assert!(matches!(expr, BoolExpr::Filter(_)));
}

#[tokio::test]
async fn test_form_c_index_produces_str_slice() {
    let cols: &'static [&'static str] = linq!(index |b: DslBlog| (b.category, b.rating));
    assert_eq!(cols.len(), 2);
    assert!(cols.contains(&"category"));
    assert!(cols.contains(&"rating"));
}

#[tokio::test]
async fn test_form_c_key_produces_str_slice() {
    let cols: &'static [&'static str] = linq!(key |b: DslBlog| b.blog_id);
    assert_eq!(cols.len(), 1);
    assert_eq!(cols[0], "blog_id");
}