Skip to main content

toasty_driver_integration_suite/tests/
starts_with.rs

1use crate::prelude::*;
2
3/// Model with a composite key (partition + sort) and a non-key string attribute.
4/// Used for all starts_with tests.
5#[derive(Debug, toasty::Model)]
6#[key(partition = partition_id, local = sort_key)]
7struct Item {
8    partition_id: i64,
9    sort_key: String,
10    name: String,
11}
12
13async fn setup(test: &mut Test) -> toasty::Db {
14    let mut db = test.setup_db(models!(Item)).await;
15
16    toasty::create!(Item::[
17        { partition_id: 1_i64, sort_key: "alpha-1", name: "Alice" },
18        { partition_id: 1_i64, sort_key: "alpha-2", name: "Alicia" },
19        { partition_id: 1_i64, sort_key: "beta-1",  name: "Bob"   },
20        { partition_id: 1_i64, sort_key: "beta-2",  name: "Barry" },
21        { partition_id: 2_i64, sort_key: "alpha-1", name: "Carol" },
22    ])
23    .exec(&mut db)
24    .await
25    .unwrap();
26
27    db
28}
29
30/// starts_with on the sort key. On DynamoDB this uses KeyConditionExpression;
31/// on SQL: SQLite/Turso use GLOB, MySQL uses BINARY LIKE, PostgreSQL uses `^@`.
32#[driver_test]
33pub async fn starts_with_sort_key(test: &mut Test) -> Result<()> {
34    let mut db = setup(test).await;
35
36    let mut items: Vec<Item> = Item::filter(
37        Item::fields()
38            .partition_id()
39            .eq(1_i64)
40            .and(Item::fields().sort_key().starts_with("alpha".to_string())),
41    )
42    .exec(&mut db)
43    .await?;
44
45    items.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
46
47    assert_eq!(items.len(), 2);
48    assert_eq!(items[0].sort_key, "alpha-1");
49    assert_eq!(items[1].sort_key, "alpha-2");
50
51    Ok(())
52}
53
54/// starts_with on a non-key attribute. On DynamoDB this uses FilterExpression;
55/// on SQL: SQLite/Turso use GLOB, MySQL uses BINARY LIKE, PostgreSQL uses `^@`.
56#[driver_test]
57pub async fn starts_with_non_key_attr(test: &mut Test) -> Result<()> {
58    let mut db = setup(test).await;
59
60    let mut items: Vec<Item> = Item::filter(
61        Item::fields()
62            .partition_id()
63            .eq(1_i64)
64            .and(Item::fields().name().starts_with("Al".to_string())),
65    )
66    .exec(&mut db)
67    .await?;
68
69    items.sort_by(|a, b| a.name.cmp(&b.name));
70
71    assert_eq!(items.len(), 2);
72    assert_eq!(items[0].name, "Alice");
73    assert_eq!(items[1].name, "Alicia");
74
75    Ok(())
76}
77
78/// starts_with with a prefix that matches nothing — returns empty result.
79#[driver_test]
80pub async fn starts_with_no_match(test: &mut Test) -> Result<()> {
81    let mut db = setup(test).await;
82
83    let items: Vec<Item> = Item::filter(
84        Item::fields()
85            .partition_id()
86            .eq(1_i64)
87            .and(Item::fields().sort_key().starts_with("gamma".to_string())),
88    )
89    .exec(&mut db)
90    .await?;
91
92    assert_eq!(items.len(), 0);
93
94    Ok(())
95}
96
97/// starts_with with an empty prefix — DynamoDB rejects empty string key values.
98#[driver_test(requires(not(sql)))]
99pub async fn starts_with_empty_prefix(test: &mut Test) -> Result<()> {
100    let mut db = setup(test).await;
101
102    let result: toasty::Result<Vec<Item>> = Item::filter(
103        Item::fields()
104            .partition_id()
105            .eq(1_i64)
106            .and(Item::fields().sort_key().starts_with("".to_string())),
107    )
108    .exec(&mut db)
109    .await;
110
111    assert!(
112        result.is_err(),
113        "expected error when using starts_with with empty prefix on DynamoDB"
114    );
115
116    Ok(())
117}
118
119/// starts_with with an empty prefix on SQL — lowers to LIKE '%', matches all rows.
120#[driver_test(requires(sql))]
121pub async fn starts_with_empty_prefix_sql(test: &mut Test) -> Result<()> {
122    let mut db = setup(test).await;
123
124    let items: Vec<Item> = Item::filter(
125        Item::fields()
126            .partition_id()
127            .eq(1_i64)
128            .and(Item::fields().sort_key().starts_with("".to_string())),
129    )
130    .exec(&mut db)
131    .await?;
132
133    assert_eq!(items.len(), 4, "empty prefix should match all rows on SQL");
134
135    Ok(())
136}
137
138/// starts_with prefix containing LIKE wildcards (`%`, `_`) and the escape
139/// char (`!`). These must match literally on all backends.
140#[driver_test]
141pub async fn starts_with_special_chars(test: &mut Test) -> Result<()> {
142    #[derive(Debug, toasty::Model)]
143    #[key(partition = partition_id, local = sort_key)]
144    struct StringItem {
145        partition_id: i64,
146        sort_key: String,
147    }
148
149    let mut db = test.setup_db(models!(StringItem)).await;
150
151    toasty::create!(StringItem::[
152        { partition_id: 1_i64, sort_key: "100%-discount" },
153        { partition_id: 1_i64, sort_key: "100xdiscount"  },
154        { partition_id: 1_i64, sort_key: "1009"          },
155        { partition_id: 1_i64, sort_key: "a_b-literal"   },
156        { partition_id: 1_i64, sort_key: "axb-wildcard"  },
157        { partition_id: 1_i64, sort_key: "!bang-literal" },
158        { partition_id: 1_i64, sort_key: "x!bang"        },
159    ])
160    .exec(&mut db)
161    .await
162    .unwrap();
163
164    // `%` must match literally, not as a wildcard.
165    let mut items: Vec<StringItem> = StringItem::filter(
166        StringItem::fields().partition_id().eq(1_i64).and(
167            StringItem::fields()
168                .sort_key()
169                .starts_with("100%".to_string()),
170        ),
171    )
172    .exec(&mut db)
173    .await?;
174    items.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
175    assert_eq!(items.len(), 1);
176    assert_eq!(items[0].sort_key, "100%-discount");
177
178    // `_` must match literally, not as a single-char wildcard.
179    let mut items: Vec<StringItem> = StringItem::filter(
180        StringItem::fields().partition_id().eq(1_i64).and(
181            StringItem::fields()
182                .sort_key()
183                .starts_with("a_b".to_string()),
184        ),
185    )
186    .exec(&mut db)
187    .await?;
188    items.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
189    assert_eq!(items.len(), 1);
190    assert_eq!(items[0].sort_key, "a_b-literal");
191
192    // `!` (the escape char chosen by the SQL lowering) must also match
193    // literally when present in the user-supplied prefix.
194    let mut items: Vec<StringItem> = StringItem::filter(
195        StringItem::fields().partition_id().eq(1_i64).and(
196            StringItem::fields()
197                .sort_key()
198                .starts_with("!bang".to_string()),
199        ),
200    )
201    .exec(&mut db)
202    .await?;
203    items.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
204    assert_eq!(items.len(), 1);
205    assert_eq!(items[0].sort_key, "!bang-literal");
206
207    Ok(())
208}
209
210/// starts_with on an `Option<String>` field — matches non-null values with
211/// the given prefix; rows with NULL values are excluded.
212#[driver_test]
213pub async fn starts_with_optional_field(test: &mut Test) -> Result<()> {
214    #[derive(Debug, toasty::Model)]
215    #[key(partition = partition_id, local = id)]
216    struct OptItem {
217        partition_id: i64,
218        id: i64,
219        nickname: Option<String>,
220    }
221
222    let mut db = test.setup_db(models!(OptItem)).await;
223
224    toasty::create!(OptItem::[
225        { partition_id: 1_i64, id: 1_i64, nickname: Some("Ali".to_string())     },
226        { partition_id: 1_i64, id: 2_i64, nickname: Some("Alicia".to_string())  },
227        { partition_id: 1_i64, id: 3_i64, nickname: Some("Bob".to_string())     },
228        { partition_id: 1_i64, id: 4_i64, nickname: None                        },
229    ])
230    .exec(&mut db)
231    .await?;
232
233    let mut items: Vec<OptItem> = OptItem::filter(
234        OptItem::fields()
235            .partition_id()
236            .eq(1_i64)
237            .and(OptItem::fields().nickname().starts_with("Al".to_string())),
238    )
239    .exec(&mut db)
240    .await?;
241
242    items.sort_by_key(|i| i.id);
243
244    assert_eq!(items.len(), 2);
245    assert_eq!(items[0].nickname.as_deref(), Some("Ali"));
246    assert_eq!(items[1].nickname.as_deref(), Some("Alicia"));
247
248    Ok(())
249}
250
251/// starts_with is case-sensitive: a lowercase prefix must not match records
252/// whose values only start with the uppercase equivalent.
253#[driver_test]
254pub async fn starts_with_case_sensitive(test: &mut Test) -> Result<()> {
255    #[derive(Debug, toasty::Model)]
256    #[key(partition = partition_id, local = sort_key)]
257    struct CaseItem {
258        partition_id: i64,
259        sort_key: String,
260        name: String,
261    }
262
263    let mut db = test.setup_db(models!(CaseItem)).await;
264
265    toasty::create!(CaseItem::[
266        { partition_id: 1_i64, sort_key: "1", name: "Alice" },
267        { partition_id: 1_i64, sort_key: "2", name: "alice" },
268        { partition_id: 1_i64, sort_key: "3", name: "ALICE" },
269    ])
270    .exec(&mut db)
271    .await?;
272
273    // Lowercase prefix — should match only the lowercase record.
274    let mut lower: Vec<CaseItem> = CaseItem::filter(
275        CaseItem::fields()
276            .partition_id()
277            .eq(1_i64)
278            .and(CaseItem::fields().name().starts_with("al".to_string())),
279    )
280    .exec(&mut db)
281    .await?;
282    lower.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
283    assert_eq!(
284        lower.len(),
285        1,
286        "lowercase prefix should match only lowercase record"
287    );
288    assert_eq!(lower[0].name, "alice");
289
290    // Uppercase prefix — should match only the uppercase record.
291    let mut upper: Vec<CaseItem> = CaseItem::filter(
292        CaseItem::fields()
293            .partition_id()
294            .eq(1_i64)
295            .and(CaseItem::fields().name().starts_with("AL".to_string())),
296    )
297    .exec(&mut db)
298    .await?;
299    upper.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
300    assert_eq!(
301        upper.len(),
302        1,
303        "uppercase prefix should match only uppercase record"
304    );
305    assert_eq!(upper[0].name, "ALICE");
306
307    Ok(())
308}
309
310/// starts_with on the partition key — on scan-capable drivers (DynamoDB) this
311/// falls back to a table scan with a begins_with filter and succeeds; on
312/// non-scan NoSQL drivers it returns an error.
313#[driver_test(requires(not(sql)))]
314pub async fn starts_with_partition_key_error(test: &mut Test) -> Result<()> {
315    #[derive(Debug, toasty::Model)]
316    #[key(partition = partition_id, local = sort_key)]
317    struct StringKeyItem {
318        partition_id: String,
319        sort_key: String,
320    }
321
322    let mut db = test.setup_db(models!(StringKeyItem)).await;
323
324    StringKeyItem::create()
325        .partition_id("hello")
326        .sort_key("world")
327        .exec(&mut db)
328        .await?;
329
330    let result = StringKeyItem::filter(
331        StringKeyItem::fields()
332            .partition_id()
333            .starts_with("hel".to_string()),
334    )
335    .exec(&mut db)
336    .await;
337
338    if test.capability().scan {
339        let items = result?;
340        assert_eq!(1, items.len());
341        assert_eq!("hello", items[0].partition_id);
342    } else {
343        assert!(
344            result.is_err(),
345            "expected error when using starts_with on partition key"
346        );
347    }
348
349    Ok(())
350}