Skip to main content

toasty_driver_integration_suite/tests/
relation_chain_composite_key.rs

1//! Chain relation methods on `Many` where one or more hops in the chain has
2//! a composite key. Parallels [`crate::tests::relation_chain`] which covers
3//! the all-single-key case.
4//!
5//! Two positions are interesting:
6//!
7//! - A `BelongsTo` second hop whose foreign key spans multiple columns.
8//! - A `HasMany` first hop whose paired `BelongsTo` on the target has a
9//!   composite foreign key.
10//!
11//! The shared scenario [`crate::scenarios::composite_chain_relations`]
12//! arranges `User → Todo → Category` so both positions are reachable from a
13//! single dataset: `Category` has composite PK `(id, revision)` and Todo's
14//! FK to it spans `(category_id, category_revision)`. The Todo→User FK is
15//! single-column, so `category.todos().user()` cross-checks that a composite
16//! first hop chains cleanly into a single-column second hop.
17
18use crate::prelude::*;
19use crate::scenarios::composite_chain_relations::Category;
20
21/// Insert a Category with both fields of its composite PK set. The scenario
22/// uses a non-auto composite PK so callers have to pick `id` and `revision`
23/// themselves; centralising that here keeps the tests focused on chain
24/// behavior rather than key plumbing.
25async fn make_category(db: &mut toasty::Db, name: &str, revision: i64) -> Result<Category> {
26    toasty::create!(Category {
27        id: uuid::Uuid::new_v4(),
28        revision,
29        name,
30    })
31    .exec(db)
32    .await
33}
34
35// =====================================================================
36// `user.todos().category()` — second hop is BelongsTo with composite FK
37// =====================================================================
38
39/// Happy path mirroring `relation_chain::user_todos_category` but the
40/// `Todo → Category` belongs_to spans `(category_id, category_revision)`.
41/// The result must dedupe on the composite key, not just on `id`.
42#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
43pub async fn user_todos_category_composite(test: &mut Test) -> Result<()> {
44    let mut db = setup(test).await;
45
46    let user = toasty::create!(User { name: "Anchovy" })
47        .exec(&mut db)
48        .await?;
49    let other_user = toasty::create!(User { name: "Other" })
50        .exec(&mut db)
51        .await?;
52
53    let food = make_category(&mut db, "Food", 1).await?;
54    let drink = make_category(&mut db, "Drink", 1).await?;
55    let _unused = make_category(&mut db, "Unused", 1).await?;
56
57    toasty::create!(Todo::[
58        { title: "salad", user: &user, category: &food },
59        { title: "tea",   user: &user, category: &drink },
60        { title: "sushi", user: &user, category: &food },
61        { title: "wine",  user: &other_user, category: &drink },
62    ])
63    .exec(&mut db)
64    .await?;
65
66    let mut categories = user.todos().category().exec(&mut db).await?;
67    categories.sort_by_key(|c| c.name.clone());
68
69    let ids: Vec<_> = categories.iter().map(|c| (c.id, c.revision)).collect();
70    assert_unique!(ids);
71    assert_eq!(categories.len(), 2);
72    assert_eq!(
73        (categories[0].id, categories[0].revision),
74        (drink.id, drink.revision)
75    );
76    assert_eq!(
77        (categories[1].id, categories[1].revision),
78        (food.id, food.revision)
79    );
80    Ok(())
81}
82
83/// Two Categories that share `id` but differ in `revision` must both come
84/// back as distinct rows from a chain query. This protects against a
85/// regression where the IN-subquery was generated over `category_id` only,
86/// silently merging different revisions.
87#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
88pub async fn user_todos_category_distinguishes_by_revision(test: &mut Test) -> Result<()> {
89    let mut db = setup(test).await;
90
91    let user = toasty::create!(User { name: "Owner" })
92        .exec(&mut db)
93        .await?;
94
95    // Same `id`, different `revision` — without composite-key handling
96    // these would collide in the FK subquery.
97    let shared_id = uuid::Uuid::new_v4();
98    let v1 = toasty::create!(Category {
99        id: shared_id,
100        revision: 1,
101        name: "v1",
102    })
103    .exec(&mut db)
104    .await?;
105    let v2 = toasty::create!(Category {
106        id: shared_id,
107        revision: 2,
108        name: "v2",
109    })
110    .exec(&mut db)
111    .await?;
112
113    toasty::create!(Todo::[
114        { title: "old", user: &user, category: &v1 },
115        { title: "new", user: &user, category: &v2 },
116    ])
117    .exec(&mut db)
118    .await?;
119
120    let mut categories = user.todos().category().exec(&mut db).await?;
121    categories.sort_by_key(|c| c.revision);
122
123    assert_eq!(categories.len(), 2);
124    assert_eq!(
125        (categories[0].id, categories[0].revision),
126        (v1.id, v1.revision)
127    );
128    assert_eq!(
129        (categories[1].id, categories[1].revision),
130        (v2.id, v2.revision)
131    );
132    Ok(())
133}
134
135/// A chain whose source produces no rows (`user` has no todos) must
136/// short-circuit to an empty result, even when there is unrelated data of
137/// the matching shape in the table.
138#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
139pub async fn composite_chain_from_empty_source_is_empty(test: &mut Test) -> Result<()> {
140    let mut db = setup(test).await;
141
142    let lonely = toasty::create!(User { name: "Lonely" })
143        .exec(&mut db)
144        .await?;
145
146    let busy = toasty::create!(User { name: "Busy" }).exec(&mut db).await?;
147    let cat = make_category(&mut db, "Solo", 1).await?;
148    toasty::create!(Todo {
149        title: "salad",
150        user: &busy,
151        category: &cat,
152    })
153    .exec(&mut db)
154    .await?;
155
156    let categories = lonely.todos().category().exec(&mut db).await?;
157    assert!(categories.is_empty());
158    Ok(())
159}
160
161/// Filter applied to the chain's terminal model narrows the chain's result.
162/// Mirrors `relation_chain::chain_then_filter` but the terminal model has a
163/// composite PK.
164#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
165pub async fn composite_chain_then_filter(test: &mut Test) -> Result<()> {
166    let mut db = setup(test).await;
167
168    let user = toasty::create!(User { name: "Filty" })
169        .exec(&mut db)
170        .await?;
171    let food = make_category(&mut db, "Food", 1).await?;
172    let drink = make_category(&mut db, "Drink", 1).await?;
173
174    toasty::create!(Todo::[
175        { title: "salad", user: &user, category: &food },
176        { title: "tea",   user: &user, category: &drink },
177    ])
178    .exec(&mut db)
179    .await?;
180
181    let only_food = user
182        .todos()
183        .category()
184        .filter(Category::fields().name().eq("Food"))
185        .exec(&mut db)
186        .await?;
187    assert_eq!(only_food.len(), 1);
188    assert_eq!(
189        (only_food[0].id, only_food[0].revision),
190        (food.id, food.revision)
191    );
192    Ok(())
193}
194
195// =====================================================================
196// `category.todos()` — first hop is HasMany whose pair is composite-FK
197// =====================================================================
198
199/// Chain starting at a model with a composite PK. The first hop's pair
200/// (Todo's `belongs_to` back to Category) is composite, so the filter
201/// generated for `Todo.category_id` must include both `category_id` and
202/// `category_revision`.
203#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
204pub async fn category_todos_user_composite_first_hop(test: &mut Test) -> Result<()> {
205    let mut db = setup(test).await;
206
207    let alice = toasty::create!(User { name: "Alice" })
208        .exec(&mut db)
209        .await?;
210    let bob = toasty::create!(User { name: "Bob" }).exec(&mut db).await?;
211
212    let food = make_category(&mut db, "Food", 1).await?;
213    let other = make_category(&mut db, "Other", 1).await?;
214
215    toasty::create!(Todo::[
216        { title: "salad", user: &alice, category: &food },
217        { title: "tea",   user: &bob,   category: &food },
218        { title: "wine",  user: &alice, category: &other },
219    ])
220    .exec(&mut db)
221    .await?;
222
223    let mut users = food.todos().user().exec(&mut db).await?;
224    users.sort_by_key(|u| u.name.clone());
225
226    let ids: Vec<_> = users.iter().map(|u| u.id).collect();
227    assert_unique!(ids);
228    assert_eq!(users.len(), 2);
229    assert_eq!(users[0].name, "Alice");
230    assert_eq!(users[1].name, "Bob");
231    Ok(())
232}
233
234/// `Category(id=X, revision=1)` and `Category(id=X, revision=2)` are two
235/// distinct categories. The chain starting at one must not pick up the
236/// other's todos — the filter has to discriminate on both FK columns.
237#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
238pub async fn category_todos_respects_revision(test: &mut Test) -> Result<()> {
239    let mut db = setup(test).await;
240
241    let alice = toasty::create!(User { name: "Alice" })
242        .exec(&mut db)
243        .await?;
244    let bob = toasty::create!(User { name: "Bob" }).exec(&mut db).await?;
245
246    // Two categories sharing the same `id`, differing only in `revision`.
247    let shared_id = uuid::Uuid::new_v4();
248    let v1 = toasty::create!(Category {
249        id: shared_id,
250        revision: 1,
251        name: "v1",
252    })
253    .exec(&mut db)
254    .await?;
255    let v2 = toasty::create!(Category {
256        id: shared_id,
257        revision: 2,
258        name: "v2",
259    })
260    .exec(&mut db)
261    .await?;
262
263    toasty::create!(Todo::[
264        { title: "for-v1", user: &alice, category: &v1 },
265        { title: "for-v2", user: &bob,   category: &v2 },
266    ])
267    .exec(&mut db)
268    .await?;
269
270    let v1_users = v1.todos().user().exec(&mut db).await?;
271    assert_eq!(v1_users.len(), 1);
272    assert_eq!(v1_users[0].name, "Alice");
273
274    let v2_users = v2.todos().user().exec(&mut db).await?;
275    assert_eq!(v2_users.len(), 1);
276    assert_eq!(v2_users[0].name, "Bob");
277    Ok(())
278}
279
280/// A `category.todos()` chain that ends in `.filter(...)` narrows the
281/// terminal model the same way as in the single-key chain tests. This
282/// pairs with `composite_chain_then_filter` (which exercises the
283/// second-hop composite case) to make sure both endpoints respect filters.
284#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
285pub async fn category_todos_filter(test: &mut Test) -> Result<()> {
286    let mut db = setup(test).await;
287
288    let alice = toasty::create!(User { name: "Alice" })
289        .exec(&mut db)
290        .await?;
291    let bob = toasty::create!(User { name: "Bob" }).exec(&mut db).await?;
292
293    let food = make_category(&mut db, "Food", 1).await?;
294
295    toasty::create!(Todo::[
296        { title: "salad",   user: &alice, category: &food },
297        { title: "sushi",   user: &bob,   category: &food },
298    ])
299    .exec(&mut db)
300    .await?;
301
302    let just_salad = food
303        .todos()
304        .filter(Todo::fields().title().eq("salad"))
305        .exec(&mut db)
306        .await?;
307    assert_eq!(just_salad.len(), 1);
308    assert_eq!(just_salad[0].title, "salad");
309    Ok(())
310}
311
312/// `Category::filter(name = ...).todos().user()` — the chain's source query
313/// is filtered by a *non-FK* column (`name`). The fallback path inside
314/// `lift_belongs_to_in_subquery` (which can't lift the filter onto FK
315/// columns) must still produce a working composite-FK IN subquery rather
316/// than panicking on `todo!("composite keys")`.
317#[driver_test(scenario(crate::scenarios::composite_chain_relations))]
318pub async fn filtered_category_todos_user_composite(test: &mut Test) -> Result<()> {
319    let mut db = setup(test).await;
320
321    let alice = toasty::create!(User { name: "Alice" })
322        .exec(&mut db)
323        .await?;
324    let bob = toasty::create!(User { name: "Bob" }).exec(&mut db).await?;
325
326    let food = make_category(&mut db, "Food", 1).await?;
327    let drink = make_category(&mut db, "Drink", 1).await?;
328
329    toasty::create!(Todo::[
330        { title: "salad", user: &alice, category: &food },
331        { title: "tea",   user: &bob,   category: &drink },
332    ])
333    .exec(&mut db)
334    .await?;
335
336    let mut users = Category::filter(Category::fields().name().eq("Food"))
337        .todos()
338        .user()
339        .exec(&mut db)
340        .await?;
341    users.sort_by_key(|u| u.name.clone());
342
343    assert_eq!(users.len(), 1);
344    assert_eq!(users[0].name, "Alice");
345    Ok(())
346}
347
348// =====================================================================
349// Both hops composite — HasMany→HasMany where parent has composite PK
350// =====================================================================
351
352/// `Author → posts → comments` with composite keys on `Author` and `Post`.
353/// The chain involves two HasMany hops, each producing an IN-subquery
354/// against a composite-FK pair. Mirrors `relation_chain::has_many_through_has_many`.
355#[driver_test]
356pub async fn composite_has_many_through_has_many(test: &mut Test) -> Result<()> {
357    #[derive(Debug, toasty::Model)]
358    #[key(id, revision)]
359    struct Author {
360        id: uuid::Uuid,
361        revision: i64,
362        name: String,
363        #[has_many]
364        posts: toasty::HasMany<Post>,
365    }
366
367    #[derive(Debug, toasty::Model)]
368    #[index(author_id, author_revision)]
369    struct Post {
370        #[key]
371        #[auto]
372        id: uuid::Uuid,
373        author_id: uuid::Uuid,
374        author_revision: i64,
375        #[belongs_to(key = [author_id, author_revision], references = [id, revision])]
376        author: toasty::BelongsTo<Author>,
377        title: String,
378        #[has_many]
379        comments: toasty::HasMany<Comment>,
380    }
381
382    #[derive(Debug, toasty::Model)]
383    struct Comment {
384        #[key]
385        #[auto]
386        id: uuid::Uuid,
387        #[index]
388        post_id: uuid::Uuid,
389        #[belongs_to(key = post_id, references = id)]
390        post: toasty::BelongsTo<Post>,
391        body: String,
392    }
393
394    let mut db = test.setup_db(models!(Author, Post, Comment)).await;
395
396    let alice = toasty::create!(Author {
397        id: uuid::Uuid::new_v4(),
398        revision: 1,
399        name: "Alice",
400    })
401    .exec(&mut db)
402    .await?;
403    let bob = toasty::create!(Author {
404        id: uuid::Uuid::new_v4(),
405        revision: 1,
406        name: "Bob",
407    })
408    .exec(&mut db)
409    .await?;
410
411    let p1 = toasty::create!(Post {
412        title: "p1",
413        author: &alice
414    })
415    .exec(&mut db)
416    .await?;
417    let p2 = toasty::create!(Post {
418        title: "p2",
419        author: &alice
420    })
421    .exec(&mut db)
422    .await?;
423    let _p3 = toasty::create!(Post {
424        title: "p3",
425        author: &bob
426    })
427    .exec(&mut db)
428    .await?;
429
430    toasty::create!(Comment::[
431        { body: "c1", post: &p1 },
432        { body: "c2", post: &p1 },
433        { body: "c3", post: &p2 },
434    ])
435    .exec(&mut db)
436    .await?;
437
438    let mut alice_comments = alice.posts().comments().exec(&mut db).await?;
439    alice_comments.sort_by_key(|c| c.body.clone());
440    let bodies: Vec<_> = alice_comments.into_iter().map(|c| c.body).collect();
441    assert_eq!(bodies, vec!["c1", "c2", "c3"]);
442
443    let bob_comments = bob.posts().comments().exec(&mut db).await?;
444    assert!(bob_comments.is_empty());
445    Ok(())
446}