Skip to main content

toasty_driver_integration_suite/tests/
index_composite.rs

1use crate::prelude::*;
2use toasty_core::driver::Operation;
3
4/// Basic composite index: model-level `#[index(field_a, field_b)]` creates a two-column
5/// index on SQL and a GSI (hash + range key) on DynamoDB.
6#[driver_test]
7pub async fn composite_index_basic(t: &mut Test) -> Result<()> {
8    #[derive(Debug, toasty::Model)]
9    #[key(user_id, game_title)]
10    #[index(game_title, top_score)]
11    struct GameScore {
12        user_id: String,
13        game_title: String,
14        top_score: i64,
15    }
16
17    let mut db = t.setup_db(models!(GameScore)).await;
18
19    toasty::create!(GameScore::[
20        { user_id: "u1", game_title: "chess", top_score: 100_i64 },
21        { user_id: "u2", game_title: "chess", top_score: 200_i64 },
22        { user_id: "u1", game_title: "go", top_score: 50_i64 },
23    ])
24    .exec(&mut db)
25    .await?;
26
27    let mut scores: Vec<GameScore> = GameScore::filter_by_game_title("chess")
28        .exec(&mut db)
29        .await?;
30    scores.sort_by_key(|s| s.top_score);
31
32    assert_eq!(scores.len(), 2);
33    assert_eq!(scores[0].top_score, 100);
34    assert_eq!(scores[1].top_score, 200);
35
36    Ok(())
37}
38
39/// Struct-level `#[index(field)]` is equivalent to field-level `#[index]` (cross-driver).
40///
41/// Verifies that `filter_by_user_id()` returns the correct records and issues an
42/// indexed operation rather than a full scan.
43#[driver_test]
44pub async fn composite_index_struct_level(t: &mut Test) -> Result<()> {
45    #[derive(Debug, toasty::Model)]
46    #[key(id, name)]
47    #[index(user_id)]
48    struct Post {
49        id: String,
50        name: String,
51        user_id: String,
52        title: String,
53    }
54
55    let mut db = t.setup_db(models!(Post)).await;
56
57    toasty::create!(Post::[
58        { id: "p1", name: "first", user_id: "alice", title: "Hello World" },
59        { id: "p2", name: "second", user_id: "alice", title: "Another Post" },
60        { id: "p3", name: "third", user_id: "bob", title: "Bob's Post" },
61    ])
62    .exec(&mut db)
63    .await?;
64
65    t.log().clear();
66
67    let mut posts: Vec<Post> = Post::filter_by_user_id("alice").exec(&mut db).await?;
68    posts.sort_by(|a, b| a.id.cmp(&b.id));
69
70    assert_eq!(posts.len(), 2);
71    assert_eq!(posts[0].title, "Hello World");
72    assert_eq!(posts[1].title, "Another Post");
73
74    // Verify that an indexed operation was issued (not a full scan)
75    let op = t.log().pop_op();
76    if t.capability().sql {
77        assert_struct!(op, Operation::QuerySql(_));
78    } else {
79        assert_struct!(op, Operation::QueryPk(_));
80    }
81
82    Ok(())
83}
84
85/// Two-column index generates prefix query methods for each valid column prefix (cross-driver).
86///
87/// `#[index(game_title, top_score)]` generates:
88/// - `filter_by_game_title()` — partition key only
89/// - `filter_by_game_title_and_top_score()` — both columns
90///
91/// Verifies both methods issue an indexed operation.
92#[driver_test]
93pub async fn composite_index_prefix_queries(t: &mut Test) -> Result<()> {
94    #[derive(Debug, toasty::Model)]
95    #[key(user_id, game_title)]
96    #[index(game_title, top_score)]
97    struct GameScore {
98        user_id: String,
99        game_title: String,
100        top_score: i64,
101    }
102
103    let mut db = t.setup_db(models!(GameScore)).await;
104
105    toasty::create!(GameScore::[
106        { user_id: "u1", game_title: "chess", top_score: 100_i64 },
107        { user_id: "u2", game_title: "chess", top_score: 200_i64 },
108        { user_id: "u3", game_title: "chess", top_score: 200_i64 },
109        { user_id: "u1", game_title: "go", top_score: 50_i64 },
110    ])
111    .exec(&mut db)
112    .await?;
113
114    t.log().clear();
115
116    // Test prefix query: partition key only
117    let scores: Vec<GameScore> = GameScore::filter_by_game_title("chess")
118        .exec(&mut db)
119        .await?;
120    assert_eq!(scores.len(), 3);
121
122    let op = t.log().pop_op();
123    if t.capability().sql {
124        assert_struct!(op, Operation::QuerySql(_));
125    } else {
126        assert_struct!(op, Operation::QueryPk(_));
127    }
128
129    t.log().clear();
130
131    // Test full key query: partition + sort key
132    let scores: Vec<GameScore> = GameScore::filter_by_game_title_and_top_score("chess", 100)
133        .exec(&mut db)
134        .await?;
135    assert_eq!(scores.len(), 1);
136    assert_eq!(scores[0].user_id, "u1");
137
138    let op = t.log().pop_op();
139    if t.capability().sql {
140        assert_struct!(op, Operation::QuerySql(_));
141    } else {
142        assert_struct!(op, Operation::QueryPk(_));
143    }
144
145    Ok(())
146}
147
148/// Multi-attribute partition key: `#[index(partition = [a, b], local = [c])]`
149/// creates a GSI with 2 HASH + 1 RANGE attributes (DDB-only).
150///
151/// Verifies prefix queries for all valid access patterns.
152#[driver_test(requires(not(sql)))]
153pub async fn composite_index_multi_hash(t: &mut Test) -> Result<()> {
154    #[derive(Debug, toasty::Model)]
155    #[key(id)]
156    #[index(partition = [tournament_id, region], local = [round])]
157    struct Match {
158        id: String,
159        tournament_id: String,
160        region: String,
161        round: String,
162        player1_id: String,
163        player2_id: String,
164    }
165
166    let mut db = t.setup_db(models!(Match)).await;
167
168    toasty::create!(Match::[
169        { id: "m1", tournament_id: "WINTER2024", region: "NA-EAST", round: "SEMIFINALS", player1_id: "alice", player2_id: "bob" },
170        { id: "m2", tournament_id: "WINTER2024", region: "NA-EAST", round: "FINALS", player1_id: "charlie", player2_id: "dave" },
171        { id: "m3", tournament_id: "WINTER2024", region: "EU-WEST", round: "SEMIFINALS", player1_id: "eve", player2_id: "frank" },
172    ])
173    .exec(&mut db)
174    .await?;
175
176    t.log().clear();
177
178    // Query by all partition key attributes (required for DDB GSI access)
179    let mut matches: Vec<Match> =
180        Match::filter_by_tournament_id_and_region("WINTER2024", "NA-EAST")
181            .exec(&mut db)
182            .await?;
183    matches.sort_by(|a, b| a.id.cmp(&b.id));
184
185    assert_eq!(matches.len(), 2);
186    assert_eq!(matches[0].round, "SEMIFINALS");
187    assert_eq!(matches[1].round, "FINALS");
188
189    let op = t.log().pop_op();
190    assert_struct!(op, Operation::QueryPk(_));
191
192    t.log().clear();
193
194    // Query by partition key + sort key prefix
195    let matches: Vec<Match> =
196        Match::filter_by_tournament_id_and_region_and_round("WINTER2024", "NA-EAST", "SEMIFINALS")
197            .exec(&mut db)
198            .await?;
199
200    assert_eq!(matches.len(), 1);
201    assert_eq!(matches[0].player1_id, "alice");
202
203    let op = t.log().pop_op();
204    assert_struct!(op, Operation::QueryPk(_));
205
206    Ok(())
207}
208
209/// Multi-attribute sort key: `#[index(partition = [a], local = [b, c])]`
210/// creates a GSI with 1 HASH + 2 RANGE attributes (DDB-only).
211///
212/// Verifies all three prefix query methods issue indexed operations.
213#[driver_test(requires(not(sql)))]
214pub async fn composite_index_multi_range(t: &mut Test) -> Result<()> {
215    #[derive(Debug, toasty::Model)]
216    #[key(id)]
217    #[index(partition = [player_id], local = [match_date, round])]
218    struct PlayerMatch {
219        id: String,
220        player_id: String,
221        match_date: String,
222        round: String,
223        opponent_id: String,
224        score: String,
225    }
226
227    let mut db = t.setup_db(models!(PlayerMatch)).await;
228
229    toasty::create!(PlayerMatch::[
230        { id: "pm1", player_id: "101", match_date: "2024-01-18", round: "SEMIFINALS", opponent_id: "102", score: "3-1" },
231        { id: "pm2", player_id: "101", match_date: "2024-01-18", round: "FINALS", opponent_id: "103", score: "2-1" },
232        { id: "pm3", player_id: "101", match_date: "2024-01-25", round: "SEMIFINALS", opponent_id: "104", score: "3-0" },
233        { id: "pm4", player_id: "999", match_date: "2024-01-18", round: "QUARTERFINALS", opponent_id: "101", score: "1-3" },
234    ])
235    .exec(&mut db)
236    .await?;
237
238    t.log().clear();
239
240    // Query by partition key only — all matches for a player
241    let matches: Vec<PlayerMatch> = PlayerMatch::filter_by_player_id("101")
242        .exec(&mut db)
243        .await?;
244    assert_eq!(matches.len(), 3);
245
246    let op = t.log().pop_op();
247    assert_struct!(op, Operation::QueryPk(_));
248
249    t.log().clear();
250
251    // Query by partition key + first sort key — all matches on a specific date
252    let matches: Vec<PlayerMatch> =
253        PlayerMatch::filter_by_player_id_and_match_date("101", "2024-01-18")
254            .exec(&mut db)
255            .await?;
256    assert_eq!(matches.len(), 2);
257
258    let op = t.log().pop_op();
259    assert_struct!(op, Operation::QueryPk(_));
260
261    t.log().clear();
262
263    // Query by partition key + both sort keys — specific match
264    let matches: Vec<PlayerMatch> = PlayerMatch::filter_by_player_id_and_match_date_and_round(
265        "101",
266        "2024-01-18",
267        "SEMIFINALS",
268    )
269    .exec(&mut db)
270    .await?;
271    assert_eq!(matches.len(), 1);
272    assert_eq!(matches[0].opponent_id, "102");
273
274    let op = t.log().pop_op();
275    assert_struct!(op, Operation::QueryPk(_));
276
277    Ok(())
278}
279
280/// Three-column composite index on SQL: `#[index(country, city, zip_code)]` (SQL-only).
281///
282/// Verifies all three prefix query methods return correct results.
283#[driver_test(requires(sql))]
284pub async fn composite_index_three_columns(t: &mut Test) -> Result<()> {
285    #[derive(Debug, toasty::Model)]
286    #[key(id)]
287    #[index(country, city, zip_code)]
288    struct Address {
289        #[auto]
290        id: u64,
291        country: String,
292        city: String,
293        zip_code: String,
294        street: String,
295    }
296
297    let mut db = t.setup_db(models!(Address)).await;
298
299    toasty::create!(Address::[
300        { country: "US", city: "Seattle", zip_code: "98101", street: "1st Ave" },
301        { country: "US", city: "Seattle", zip_code: "98102", street: "2nd Ave" },
302        { country: "US", city: "Portland", zip_code: "97201", street: "Oak St" },
303        { country: "CA", city: "Toronto", zip_code: "M5V", street: "King St" },
304    ])
305    .exec(&mut db)
306    .await?;
307
308    t.log().clear();
309
310    // 1-column prefix: country only
311    let addrs: Vec<Address> = Address::filter_by_country("US").exec(&mut db).await?;
312    assert_eq!(addrs.len(), 3);
313
314    let op = t.log().pop_op();
315    assert_struct!(op, Operation::QuerySql(_));
316
317    t.log().clear();
318
319    // 2-column prefix: country + city
320    let addrs: Vec<Address> = Address::filter_by_country_and_city("US", "Seattle")
321        .exec(&mut db)
322        .await?;
323    assert_eq!(addrs.len(), 2);
324
325    let op = t.log().pop_op();
326    assert_struct!(op, Operation::QuerySql(_));
327
328    t.log().clear();
329
330    // 3-column full key: country + city + zip_code
331    let addrs: Vec<Address> =
332        Address::filter_by_country_and_city_and_zip_code("US", "Seattle", "98101")
333            .exec(&mut db)
334            .await?;
335    assert_eq!(addrs.len(), 1);
336    assert_eq!(addrs[0].street, "1st Ave");
337
338    let op = t.log().pop_op();
339    assert_struct!(op, Operation::QuerySql(_));
340
341    Ok(())
342}
343
344/// Three-column simple-mode index on DynamoDB: `#[index(country, city, zip_code)]` (DDB-only).
345///
346/// In simple mode, the first field becomes HASH and the rest become RANGE.
347/// Verifies all three prefix query methods issue `QueryPk` and return correct results.
348#[driver_test(requires(not(sql)))]
349pub async fn composite_index_simple_three_column_ddb(t: &mut Test) -> Result<()> {
350    #[derive(Debug, toasty::Model)]
351    #[key(id)]
352    #[index(country, city, zip_code)]
353    struct Address {
354        id: String,
355        country: String,
356        city: String,
357        zip_code: String,
358        street: String,
359    }
360
361    let mut db = t.setup_db(models!(Address)).await;
362
363    toasty::create!(Address::[
364        { id: "a1", country: "US", city: "Seattle", zip_code: "98101", street: "1st Ave" },
365        { id: "a2", country: "US", city: "Seattle", zip_code: "98102", street: "2nd Ave" },
366        { id: "a3", country: "US", city: "Portland", zip_code: "97201", street: "Oak St" },
367        { id: "a4", country: "CA", city: "Toronto", zip_code: "M5V", street: "King St" },
368    ])
369    .exec(&mut db)
370    .await?;
371
372    t.log().clear();
373
374    // 1-column prefix: HASH key only
375    let addrs: Vec<Address> = Address::filter_by_country("US").exec(&mut db).await?;
376    assert_eq!(addrs.len(), 3);
377
378    let op = t.log().pop_op();
379    assert_struct!(op, Operation::QueryPk(_));
380
381    t.log().clear();
382
383    // 2-column prefix: HASH + first RANGE key
384    let addrs: Vec<Address> = Address::filter_by_country_and_city("US", "Seattle")
385        .exec(&mut db)
386        .await?;
387    assert_eq!(addrs.len(), 2);
388
389    let op = t.log().pop_op();
390    assert_struct!(op, Operation::QueryPk(_));
391
392    t.log().clear();
393
394    // 3-column full key: HASH + both RANGE keys
395    let addrs: Vec<Address> =
396        Address::filter_by_country_and_city_and_zip_code("US", "Seattle", "98101")
397            .exec(&mut db)
398            .await?;
399    assert_eq!(addrs.len(), 1);
400    assert_eq!(addrs[0].street, "1st Ave");
401
402    let op = t.log().pop_op();
403    assert_struct!(op, Operation::QueryPk(_));
404
405    Ok(())
406}
407
408/// Multiple indexes on the same model: verifies the query planner selects the correct
409/// index when a model defines two `#[index]` attributes (cross-driver).
410///
411/// A bug in index selection could silently route queries through the wrong index,
412/// returning incorrect results.
413#[driver_test]
414pub async fn composite_index_multiple_indexes(t: &mut Test) -> Result<()> {
415    #[derive(Debug, toasty::Model)]
416    #[key(id)]
417    #[index(category)]
418    #[index(brand)]
419    struct Product {
420        id: String,
421        category: String,
422        brand: String,
423        name: String,
424    }
425
426    let mut db = t.setup_db(models!(Product)).await;
427
428    toasty::create!(Product::[
429        { id: "p1", category: "electronics", brand: "acme", name: "Widget A" },
430        { id: "p2", category: "electronics", brand: "globex", name: "Widget B" },
431        { id: "p3", category: "clothing", brand: "acme", name: "Shirt C" },
432        { id: "p4", category: "clothing", brand: "initech", name: "Pants D" },
433    ])
434    .exec(&mut db)
435    .await?;
436
437    t.log().clear();
438
439    // Query via the first index (category)
440    let mut products: Vec<Product> = Product::filter_by_category("electronics")
441        .exec(&mut db)
442        .await?;
443    products.sort_by(|a, b| a.id.cmp(&b.id));
444
445    assert_eq!(products.len(), 2);
446    assert_eq!(products[0].name, "Widget A");
447    assert_eq!(products[1].name, "Widget B");
448
449    let op = t.log().pop_op();
450    if t.capability().sql {
451        assert_struct!(op, Operation::QuerySql(_));
452    } else {
453        assert_struct!(op, Operation::QueryPk(_));
454    }
455
456    t.log().clear();
457
458    // Query via the second index (brand) — must not use the category index
459    let mut products: Vec<Product> = Product::filter_by_brand("acme").exec(&mut db).await?;
460    products.sort_by(|a, b| a.id.cmp(&b.id));
461
462    assert_eq!(products.len(), 2);
463    assert_eq!(products[0].name, "Widget A");
464    assert_eq!(products[1].name, "Shirt C");
465
466    let op = t.log().pop_op();
467    if t.capability().sql {
468        assert_struct!(op, Operation::QuerySql(_));
469    } else {
470        assert_struct!(op, Operation::QueryPk(_));
471    }
472
473    Ok(())
474}
475
476/// Maximum attribute boundary: `#[index(partition = [f1, f2, f3, f4], local = [f5, f6, f7, f8])]` (DDB-only).
477///
478/// DynamoDB allows up to 4 HASH + 4 RANGE attributes in a GSI KeySchema.
479/// Verifies that `setup_db()` succeeds at the limit and that a query using all
480/// 4 partition key attributes returns correct results.
481#[driver_test(requires(not(sql)))]
482#[allow(clippy::too_many_arguments)]
483pub async fn composite_index_max_attributes(t: &mut Test) -> Result<()> {
484    #[derive(Debug, toasty::Model)]
485    #[key(id)]
486    #[index(partition = [f1, f2, f3, f4], local = [f5, f6, f7, f8])]
487    struct MaxIndex {
488        id: String,
489        f1: String,
490        f2: String,
491        f3: String,
492        f4: String,
493        f5: String,
494        f6: String,
495        f7: String,
496        f8: String,
497        value: String,
498    }
499
500    // setup_db must succeed at the 4+4 boundary
501    let mut db = t.setup_db(models!(MaxIndex)).await;
502
503    toasty::create!(MaxIndex::[
504        { id: "r1", f1: "a1", f2: "b1", f3: "c1", f4: "d1", f5: "e1", f6: "g1", f7: "h1", f8: "i1", value: "found" },
505        { id: "r2", f1: "a1", f2: "b1", f3: "c1", f4: "d2", f5: "e1", f6: "g1", f7: "h1", f8: "i1", value: "other" },
506    ])
507    .exec(&mut db)
508    .await?;
509
510    t.log().clear();
511
512    // Query using all 4 partition key attributes (required for DDB multi-attribute HASH key)
513    let records: Vec<MaxIndex> =
514        MaxIndex::filter_by_f1_and_f2_and_f3_and_f4("a1", "b1", "c1", "d1")
515            .exec(&mut db)
516            .await?;
517
518    assert_eq!(records.len(), 1);
519    assert_eq!(records[0].value, "found");
520
521    let op = t.log().pop_op();
522    assert_struct!(op, Operation::QueryPk(_));
523
524    Ok(())
525}
526
527/// Error condition: more than 4 RANGE columns in simple-mode index (DDB-only).
528///
529/// `#[index(a, b, c, d, e, f)]` in simple mode produces 1 HASH + 5 RANGE, which
530/// exceeds the DynamoDB limit of 4. The driver must return `Err(invalid_schema)`
531/// rather than panicking.
532#[driver_test(requires(not(sql)))]
533#[allow(clippy::too_many_arguments)]
534pub async fn composite_index_too_many_range_columns(t: &mut Test) -> Result<()> {
535    #[derive(Debug, toasty::Model)]
536    #[key(id)]
537    #[index(a, b, c, d, e, f)]
538    struct TooManyRange {
539        id: String,
540        a: String,
541        b: String,
542        c: String,
543        d: String,
544        e: String,
545        f: String,
546    }
547
548    // Do NOT use `?` — capture the error instead of propagating it
549    let result = t.try_setup_db(models!(TooManyRange)).await;
550
551    assert!(
552        result.is_err(),
553        "expected setup_db to fail for 1 HASH + 5 RANGE index"
554    );
555    let err = result.unwrap_err();
556    assert!(
557        err.is_invalid_schema(),
558        "expected invalid_schema error, got: {err}"
559    );
560
561    Ok(())
562}
563
564/// Range filter chained onto a composite index partition query (cross-driver).
565///
566/// `filter_by_game_title("chess")` uses the index to scope by partition key,
567/// then `.filter(GameScore::fields().top_score().gt(150))` applies a range
568/// condition on the sort key.
569#[driver_test]
570pub async fn composite_index_sort_key_range_filter(t: &mut Test) -> Result<()> {
571    #[derive(Debug, toasty::Model)]
572    #[key(user_id, game_title)]
573    #[index(game_title, top_score)]
574    struct GameScore {
575        user_id: String,
576        game_title: String,
577        top_score: i64,
578    }
579
580    let mut db = t.setup_db(models!(GameScore)).await;
581
582    toasty::create!(GameScore::[
583        { user_id: "u1", game_title: "chess", top_score: 100_i64 },
584        { user_id: "u2", game_title: "chess", top_score: 200_i64 },
585        { user_id: "u3", game_title: "chess", top_score: 1500_i64 },
586        { user_id: "u4", game_title: "chess", top_score: 50_i64 },
587        { user_id: "u1", game_title: "go", top_score: 9999_i64 },
588    ])
589    .exec(&mut db)
590    .await?;
591
592    let mut scores: Vec<GameScore> = GameScore::filter_by_game_title("chess")
593        .filter(GameScore::fields().top_score().gt(150))
594        .exec(&mut db)
595        .await?;
596    scores.sort_by_key(|s| s.top_score);
597
598    assert_eq!(scores.len(), 2);
599    assert_eq!(scores[0].top_score, 200);
600    assert_eq!(scores[1].top_score, 1500);
601
602    // go scores must not appear despite having top_score > 150
603    assert!(scores.iter().all(|s| s.game_title == "chess"));
604
605    Ok(())
606}