bsql 0.26.4

Safe SQL for Rust — if it compiles, the SQL is correct
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
//! Integration tests for v0.3: dynamic queries (optional clauses).
//!
//! Requires a running PostgreSQL with the test schema.
//! Set BSQL_DATABASE_URL=postgres://bsql:bsql@localhost/bsql_test

use bsql::Pool;

async fn pool() -> Pool {
    Pool::connect("postgres://bsql:bsql@localhost/bsql_test")
        .await
        .expect("Failed to connect to test database. Is PostgreSQL running?")
}

// --- Single optional clause ---

#[tokio::test]
async fn one_optional_clause_some() {
    let pool = pool().await;
    let dept: Option<i32> = Some(1);
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // With dept=Some(1), only tickets in department 1 should be returned.
    // Our seed data doesn't have department_id set, so this should return 0.
    assert!(
        results.is_empty(),
        "no seed tickets have department_id=1: got {} rows",
        results.len()
    );
}

#[tokio::test]
async fn one_optional_clause_none() {
    let pool = pool().await;
    let dept: Option<i32> = None;
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // With dept=None, the clause is excluded -- returns all non-deleted tickets.
    assert!(
        results.len() >= 2,
        "expected at least 2 tickets, got {}",
        results.len()
    );
}

// --- Two optional clauses: all 4 combinations ---

#[tokio::test]
async fn two_optional_clauses_none_none() {
    let pool = pool().await;
    let dept: Option<i32> = None;
    let assignee: Option<i32> = None;
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         [AND assignee_id = $assignee: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    assert!(
        results.len() >= 2,
        "both None -- should return all tickets, got {}",
        results.len()
    );
}

#[tokio::test]
async fn two_optional_clauses_some_none() {
    let pool = pool().await;
    let dept: Option<i32> = Some(999);
    let assignee: Option<i32> = None;
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         [AND assignee_id = $assignee: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    assert!(
        results.is_empty(),
        "dept=999 -- should return 0 tickets, got {}",
        results.len()
    );
}

#[tokio::test]
async fn two_optional_clauses_none_some() {
    let pool = pool().await;
    let dept: Option<i32> = None;
    let assignee: Option<i32> = Some(999);
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         [AND assignee_id = $assignee: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    assert!(
        results.is_empty(),
        "assignee=999 -- should return 0 tickets, got {}",
        results.len()
    );
}

#[tokio::test]
async fn two_optional_clauses_some_some() {
    let pool = pool().await;
    let dept: Option<i32> = Some(999);
    let assignee: Option<i32> = Some(999);
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         [AND assignee_id = $assignee: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    assert!(
        results.is_empty(),
        "both=999 -- should return 0 tickets, got {}",
        results.len()
    );
}

// --- Optional clause with base required params ---

#[tokio::test]
async fn optional_clause_with_base_params() {
    let pool = pool().await;
    let uid = 1i32;
    let dept: Option<i32> = None;
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE created_by_user_id = $uid: i32
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // uid=1 (alice) has tickets. dept=None means no department filter.
    assert!(!results.is_empty(), "alice should have tickets, got 0");
}

#[tokio::test]
async fn optional_clause_with_base_params_filtered() {
    let pool = pool().await;
    let uid = 1i32;
    let dept: Option<i32> = Some(999);
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE created_by_user_id = $uid: i32
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // uid=1 (alice) has tickets but none in dept 999
    assert!(
        results.is_empty(),
        "alice has no tickets in dept 999, got {}",
        results.len()
    );
}

// --- fetch_one and fetch_optional with optional clauses ---

#[tokio::test]
async fn optional_clause_fetch_optional_found() {
    let pool = pool().await;
    let login = "alice";
    let middle: Option<&str> = None;
    let result = bsql::query!(
        "SELECT id, login FROM users
         WHERE login = $login: &str
         [AND middle_name = $middle: Option<&str>]"
    )
    .fetch_optional(&pool)
    .await
    .unwrap();

    assert!(result.is_some());
    assert_eq!(result.unwrap().login, "alice");
}

#[tokio::test]
async fn optional_clause_fetch_optional_not_found() {
    let pool = pool().await;
    let login = "alice";
    let middle: Option<&str> = Some("NonexistentMiddle");
    let result = bsql::query!(
        "SELECT id, login FROM users
         WHERE login = $login: &str
         [AND middle_name = $middle: Option<&str>]"
    )
    .fetch_optional(&pool)
    .await
    .unwrap();

    // alice has no middle name (NULL), so middle_name = 'NonexistentMiddle' won't match
    assert!(result.is_none());
}

// --- execute with optional clause ---

#[tokio::test]
async fn optional_clause_execute() {
    let pool = pool().await;
    let dept: Option<i32> = Some(999);

    // UPDATE with optional clause -- should affect 0 rows (no tickets in dept 999)
    let affected = bsql::query!(
        "UPDATE tickets SET description = 'test'
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]"
    )
    .execute(&pool)
    .await
    .unwrap();

    assert_eq!(affected, 0);
}

#[tokio::test]
async fn optional_clause_execute_via_transaction() {
    let pool = pool().await;
    let mut tx = pool.begin().await.unwrap();

    let dept: Option<i32> = Some(999);
    let affected = bsql::query!(
        "UPDATE tickets SET description = 'tx_dyn'
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]"
    )
    .execute(&mut tx)
    .await
    .unwrap();
    assert_eq!(affected, 0);

    tx.rollback().await.unwrap();
}

#[tokio::test]
async fn optional_clause_execute_via_conn() {
    let pool = pool().await;
    let mut conn = pool.acquire().await.unwrap();

    let dept: Option<i32> = Some(999);
    let affected = bsql::query!(
        "UPDATE tickets SET description = 'conn_dyn'
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]"
    )
    .execute(&mut conn)
    .await
    .unwrap();
    assert_eq!(affected, 0);
}

// --- Three optional clauses ---

#[tokio::test]
async fn three_optional_clauses() {
    let pool = pool().await;
    let dept: Option<i32> = None;
    let assignee: Option<i32> = None;
    let creator: Option<i32> = Some(1);
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         [AND assignee_id = $assignee: Option<i32>]
         [AND created_by_user_id = $creator: Option<i32>]
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // Only creator=1 (alice) filter active, dept and assignee excluded
    assert!(!results.is_empty(), "alice has tickets");
}

// --- Optional clause with ILIKE pattern ---

#[tokio::test]
async fn optional_clause_ilike_pattern() {
    let pool = pool().await;
    let search: Option<String> = Some("login".to_owned());
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND title ILIKE '%' || $search: Option<String> || '%']
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // "Fix login bug" should match
    assert!(
        results.iter().any(|r| r.title.contains("login")),
        "should find 'Fix login bug': {results:?}"
    );
}

#[tokio::test]
async fn optional_clause_ilike_pattern_none() {
    let pool = pool().await;
    let search: Option<String> = None;
    let results = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND title ILIKE '%' || $search: Option<String> || '%']
         ORDER BY id"
    )
    .fetch_all(&pool)
    .await
    .unwrap();

    // No search filter -- returns all non-deleted tickets
    assert!(results.len() >= 2);
}

// --- T-2: Streaming + dynamic queries ---

#[tokio::test]
async fn stream_with_optional_clause_none() {
    let pool = pool().await;
    let dept: Option<i32> = None;
    let mut stream = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_stream(&pool)
    .await
    .unwrap();

    let mut count = 0;
    while let Some(ticket) = stream.next().await.unwrap() {
        count += 1;
        assert!(!ticket.title.is_empty());
    }
    // dept=None -- all non-deleted tickets
    assert!(count >= 2, "expected at least 2 tickets, got {count}");
}

#[tokio::test]
async fn stream_with_optional_clause_some() {
    let pool = pool().await;
    let dept: Option<i32> = Some(999);
    let mut stream = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE deleted_at IS NULL
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_stream(&pool)
    .await
    .unwrap();

    let mut count = 0;
    while let Some(_ticket) = stream.next().await.unwrap() {
        count += 1;
    }
    // dept=999 -- no tickets
    assert_eq!(count, 0, "no tickets in dept 999, got {count}");
}

#[tokio::test]
async fn stream_with_optional_clause_and_base_params() {
    let pool = pool().await;
    let uid = 1i32;
    let dept: Option<i32> = None;
    let mut stream = bsql::query!(
        "SELECT id, title FROM tickets
         WHERE created_by_user_id = $uid: i32
         [AND department_id = $dept: Option<i32>]
         ORDER BY id"
    )
    .fetch_stream(&pool)
    .await
    .unwrap();

    let mut count = 0;
    while let Some(_ticket) = stream.next().await.unwrap() {
        count += 1;
    }
    assert!(count >= 1, "alice should have tickets, got {count}");
}