rustlavel-db 0.1.1

Rustlavel database layer: PostgreSQL driver, query builder, migrations, and ORM
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
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
//! Integration tests against a real PostgreSQL server.
//!
//! They run only when `DATABASE_URL` is set, so `cargo test` stays green on a
//! machine with no database. Start one with:
//!
//! ```text
//! docker run -d --name rustlavel-pg -e POSTGRES_PASSWORD=secret \
//!   -e POSTGRES_USER=rustlavel -e POSTGRES_DB=rustlavel_test \
//!   -p 55432:5432 postgres:16
//! export DATABASE_URL=postgres://rustlavel:secret@127.0.0.1:55432/rustlavel_test
//! ```

use rustlavel_db::migration;
use rustlavel_db::prelude::*;
use rustlavel_db::{Migration, Value, migration::MigrationReport};

/// Skip the test when no database is configured, saying so out loud.
macro_rules! database {
    () => {
        match std::env::var("DATABASE_URL") {
            Ok(url) if !url.is_empty() => match Database::connect(&url).await {
                Ok(db) => db,
                Err(e) => panic!("DATABASE_URL is set but connecting failed: {e}"),
            },
            _ => {
                eprintln!("skipped: set DATABASE_URL to run the PostgreSQL integration tests");
                return;
            }
        }
    };
}

/// Each test owns a uniquely named table, so they can run concurrently against
/// one database without stepping on each other.
async fn fresh_table(db: &Database, name: &str) -> String {
    let table = format!("t_{name}");
    db.run(&format!("drop table if exists {table} cascade")).await.unwrap();
    table
}

#[tokio::test]
async fn connects_and_runs_a_query() {
    let db = database!();

    let answer = db.scalar::<i64>("select 40 + $1", &[Value::from(2)]).await.unwrap();
    assert_eq!(answer, Some(42));
}

#[tokio::test]
async fn round_trips_every_supported_type() {
    let db = database!();

    let row = db
        .select_one(
            "select $1::boolean as flag, $2::bigint as count, $3::double precision as ratio, \
             $4::text as label, $5::jsonb as payload, $6::bytea as blob, null::text as missing",
            &[
                Value::from(true),
                Value::from(9_000_000_000i64),
                Value::from(1.5),
                Value::from("hello"),
                Value::from(Json::parse(r#"{"a":[1,2]}"#).unwrap()),
                Value::from(vec![0xde, 0xad, 0xbe, 0xef]),
            ],
        )
        .await
        .unwrap()
        .expect("one row");

    assert!(row.get::<bool>("flag").unwrap());
    assert_eq!(row.get::<i64>("count").unwrap(), 9_000_000_000);
    assert_eq!(row.get::<f64>("ratio").unwrap(), 1.5);
    assert_eq!(row.get::<String>("label").unwrap(), "hello");
    assert_eq!(row.get::<Json>("payload").unwrap().get("a.1").unwrap().as_i64(), Some(2));
    assert_eq!(row.get::<Vec<u8>>("blob").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
    assert_eq!(row.get::<Option<String>>("missing").unwrap(), None);
}

#[tokio::test]
async fn a_parameter_can_never_become_sql() {
    let db = database!();
    let table = fresh_table(&db, "injection").await;

    db.run(&format!("create table {table} (id bigserial primary key, name text not null)"))
        .await
        .unwrap();

    let hostile = "'; drop table ".to_string() + &table + "; --";
    db.table(&table).insert_without_id(&db, &[("name", Value::from(hostile.as_str()))]).await.unwrap();

    // The table still exists and holds the string verbatim.
    let stored = db.scalar::<String>(&format!("select name from {table}"), &[]).await.unwrap();
    assert_eq!(stored.as_deref(), Some(hostile.as_str()));
}

#[tokio::test]
async fn the_query_builder_reads_and_writes() {
    let db = database!();
    let table = fresh_table(&db, "builder").await;

    Schema::new(&db)
        .create(&table, |t| {
            t.id();
            t.string("name");
            t.integer("age");
            t.boolean("active").default_bool(true);
        })
        .await
        .unwrap();

    for (name, age) in [("Ada", 36), ("Grace", 45), ("Alan", 41)] {
        db.table(&table)
            .insert(&db, &[("name", Value::from(name)), ("age", Value::from(age))])
            .await
            .unwrap();
    }

    let adults = db.table(&table).filter_op("age", ">", 40).latest("age").get(&db).await.unwrap();
    assert_eq!(adults.len(), 2);
    assert_eq!(adults[0].get::<String>("name").unwrap(), "Grace");

    assert_eq!(db.table(&table).count(&db).await.unwrap(), 3);
    assert!(db.table(&table).filter("name", "Ada").exists(&db).await.unwrap());

    let updated = db
        .table(&table)
        .filter("name", "Ada")
        .update(&db, &[("age", Value::from(37))])
        .await
        .unwrap();
    assert_eq!(updated, 1);

    let deleted = db.table(&table).filter("name", "Alan").delete(&db).await.unwrap();
    assert_eq!(deleted, 1);
    assert_eq!(db.table(&table).count(&db).await.unwrap(), 2);
}

#[tokio::test]
async fn a_transaction_commits_or_rolls_back_as_a_unit() {
    let db = database!();
    let table = fresh_table(&db, "tx").await;

    db.run(&format!("create table {table} (id bigserial primary key, name text not null)"))
        .await
        .unwrap();

    // Abandoned without committing: nothing is written.
    {
        let mut tx = db.begin().await.unwrap();
        tx.execute(&format!("insert into {table} (name) values ($1)"), &[Value::from("first")])
            .await
            .unwrap();
        tx.rollback().await.unwrap();
    }
    assert_eq!(db.table(&table).count(&db).await.unwrap(), 0);

    // Committed: it is.
    let mut tx = db.begin().await.unwrap();
    tx.execute(&format!("insert into {table} (name) values ($1)"), &[Value::from("kept")])
        .await
        .unwrap();
    tx.commit().await.unwrap();

    assert_eq!(db.table(&table).count(&db).await.unwrap(), 1);
}

#[tokio::test]
async fn dropping_a_transaction_rolls_it_back() {
    let db = database!();
    let table = fresh_table(&db, "txdrop").await;

    db.run(&format!("create table {table} (id bigserial primary key, name text not null)"))
        .await
        .unwrap();

    {
        let mut tx = db.begin().await.unwrap();
        tx.execute(&format!("insert into {table} (name) values ($1)"), &[Value::from("ghost")])
            .await
            .unwrap();
        // No commit: the guard rolls back when it goes out of scope.
    }

    // The rollback is spawned, so give it a moment to reach the server.
    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    assert_eq!(db.table(&table).count(&db).await.unwrap(), 0);
}

#[tokio::test]
async fn a_savepoint_undoes_only_part_of_a_transaction() {
    let db = database!();
    let table = fresh_table(&db, "savepoint").await;

    db.run(&format!("create table {table} (id bigserial primary key, name text not null)"))
        .await
        .unwrap();

    let mut tx = db.begin().await.unwrap();
    tx.execute(&format!("insert into {table} (name) values ($1)"), &[Value::from("keep")])
        .await
        .unwrap();
    tx.savepoint("sp1").await.unwrap();
    tx.execute(&format!("insert into {table} (name) values ($1)"), &[Value::from("discard")])
        .await
        .unwrap();
    tx.rollback_to("sp1").await.unwrap();
    tx.commit().await.unwrap();

    let names = db.table(&table).get(&db).await.unwrap();
    assert_eq!(names.len(), 1);
    assert_eq!(names[0].get::<String>("name").unwrap(), "keep");
}

// --- Migrations ---

migration!(
    CreateWidgets,
    "2026_08_29_000001_create_widgets_table",
    up: |schema| {
        schema
            .create("m_widgets", |t| {
                t.id();
                t.string("name").unique();
                t.timestamps();
            })
            .await
    },
    down: |schema| { schema.drop("m_widgets").await },
);

#[tokio::test]
async fn migrations_apply_are_idempotent_and_roll_back() {
    let db = database!();
    db.run("drop table if exists m_widgets cascade").await.unwrap();
    db.run("drop table if exists db_test_migrations").await.ok();

    let migrations: Vec<&dyn Migration> = vec![&CreateWidgets];
    // Its own tracking table: the queue crate's suite runs against the same
    // database, and a shared table means one suite rolls back the other's batch.
    let migrator = Migrator::new(&db, migrations).with_table("db_test_migrations").unwrap();

    let report = migrator.run().await.unwrap();
    assert_eq!(report.applied, vec!["2026_08_29_000001_create_widgets_table"]);
    assert!(Schema::new(&db).has_table("m_widgets").await.unwrap());

    // Running again does nothing, which is what makes deploys safe to repeat.
    let again = migrator.run().await.unwrap();
    assert_eq!(again, MigrationReport { applied: vec![], rolled_back: vec![], skipped: 1 });

    let rolled = migrator.rollback().await.unwrap();
    assert_eq!(rolled.rolled_back, vec!["2026_08_29_000001_create_widgets_table"]);
    assert!(!Schema::new(&db).has_table("m_widgets").await.unwrap());
}

// --- The ORM ---

#[derive(Model, Default, Debug, Clone, PartialEq)]
#[model(table = "orm_authors", crate = "rustlavel_db")]
struct Author {
    #[model(primary_key, generated)]
    id: i64,
    name: String,
    email: Option<String>,
}

#[tokio::test]
async fn a_derived_model_can_be_created_read_updated_and_deleted() {
    let db = database!();
    db.run("drop table if exists orm_authors cascade").await.unwrap();

    Schema::new(&db)
        .create("orm_authors", |t| {
            t.id();
            t.string("name");
            t.string("email").nullable();
        })
        .await
        .unwrap();

    let mut author = Author { name: "Ada".into(), email: Some("ada@example.com".into()), ..Author::default() };
    author.insert(&db).await.unwrap();
    assert!(author.id > 0, "the database should have assigned an id");

    let loaded = Author::find(&db, author.id).await.unwrap().expect("the author exists");
    assert_eq!(loaded, author);

    author.name = "Ada Lovelace".into();
    assert_eq!(author.update(&db).await.unwrap(), 1);
    assert_eq!(Author::find_or_fail(&db, author.id).await.unwrap().name, "Ada Lovelace");

    assert_eq!(Author::count(&db).await.unwrap(), 1);

    author.delete(&db).await.unwrap();
    assert!(Author::find(&db, author.id).await.unwrap().is_none());

    let missing = Author::find_or_fail(&db, 999_999).await.unwrap_err().to_string();
    assert!(missing.contains("no orm_authors with id"));
}

/// A second pair of tables, so the relation test never races the CRUD test for
/// the same schema objects while both run concurrently.
#[derive(Model, Default, Debug, Clone)]
#[model(table = "rel_authors", crate = "rustlavel_db")]
struct RelAuthor {
    #[model(primary_key, generated)]
    id: i64,
    name: String,
}

#[derive(Model, Default, Debug, Clone)]
#[model(table = "rel_books", crate = "rustlavel_db")]
struct RelBook {
    #[model(primary_key, generated)]
    id: i64,
    author_id: i64,
    title: String,
}

#[tokio::test]
async fn relations_load_without_an_n_plus_one() {
    let db = database!();
    db.run("drop table if exists rel_books cascade").await.unwrap();
    db.run("drop table if exists rel_authors cascade").await.unwrap();

    let schema = Schema::new(&db);
    schema
        .create("rel_authors", |t| {
            t.id();
            t.string("name");
        })
        .await
        .unwrap();
    schema
        .create("rel_books", |t| {
            t.id();
            t.big_integer("author_id").references("rel_authors", "id").cascade_on_delete();
            t.string("title");
        })
        .await
        .unwrap();

    let mut ada = RelAuthor { name: "Ada".into(), ..RelAuthor::default() };
    ada.insert(&db).await.unwrap();
    let mut grace = RelAuthor { name: "Grace".into(), ..RelAuthor::default() };
    grace.insert(&db).await.unwrap();

    for title in ["Notes", "Sketch"] {
        let mut book = RelBook { author_id: ada.id, title: title.into(), ..RelBook::default() };
        book.insert(&db).await.unwrap();
    }
    let mut cobol = RelBook { author_id: grace.id, title: "COBOL".into(), ..RelBook::default() };
    cobol.insert(&db).await.unwrap();

    let authors = RelAuthor::all(&db).await.unwrap();
    // Two queries in total, however many authors there are.
    let books = has_many::<RelAuthor, RelBook>(&db, &authors, "author_id").await.unwrap();

    assert_eq!(books.len(), 2);
    assert_eq!(books[0].len(), 2);
    assert_eq!(books[1].len(), 1);
    assert_eq!(books[1][0].title, "COBOL");

    let all_books = RelBook::all(&db).await.unwrap();
    let owners = belongs_to::<RelBook, RelAuthor>(&db, &all_books, "author_id").await.unwrap();

    assert_eq!(owners.len(), 3);
    assert!(owners.iter().all(Option::is_some));
    assert_eq!(owners[2].as_ref().unwrap().name, "Grace");
}

#[tokio::test]
async fn the_schema_builder_creates_real_tables() {
    let db = database!();
    let table = fresh_table(&db, "schema").await;

    let schema = Schema::new(&db);
    schema
        .create(&table, |t| {
            t.id();
            t.string("email").unique();
            t.decimal("balance", 12, 2).default_raw("0");
            t.json("settings").nullable();
            t.timestamps();
            t.soft_deletes();
        })
        .await
        .unwrap();

    assert!(schema.has_table(&table).await.unwrap());
    assert!(schema.has_column(&table, "deleted_at").await.unwrap());
    assert!(!schema.has_column(&table, "nickname").await.unwrap());

    schema.alter(&table, |t| { t.string("nickname").nullable(); }).await.unwrap();
    assert!(schema.has_column(&table, "nickname").await.unwrap());

    // The unique index is real: a duplicate is rejected by the database.
    db.table(&table).insert_without_id(&db, &[("email", Value::from("a@b.com"))]).await.unwrap();
    let duplicate =
        db.table(&table).insert_without_id(&db, &[("email", Value::from("a@b.com"))]).await;
    assert!(duplicate.is_err());

    schema.drop(&table).await.unwrap();
    assert!(!schema.has_table(&table).await.unwrap());
}

#[tokio::test]
async fn a_broken_statement_reports_the_sql() {
    let db = database!();

    let error = db.select("select * from a_table_that_is_not_there", &[]).await.unwrap_err();
    let text = error.to_string();

    assert!(text.contains("42P01"), "should carry the SQLSTATE: {text}");
    assert!(text.contains("SQL: select * from a_table_that_is_not_there"));
}

#[tokio::test]
async fn the_pool_reuses_connections() {
    let db = database!();

    for _ in 0..5 {
        db.scalar::<i64>("select 1", &[]).await.unwrap();
        // Give the connection time to be handed back before the next round.
        tokio::task::yield_now().await;
    }

    assert!(db.pool().idle_count().await >= 1, "a connection should have returned to the pool");
}

#[tokio::test]
async fn pagination_walks_a_table_both_ways() {
    let db = database!();
    let table = fresh_table(&db, "paging").await;

    Schema::new(&db)
        .create(&table, |t| {
            t.id();
            t.string("title");
        })
        .await
        .unwrap();

    for index in 1..=25 {
        db.table(&table)
            .insert_without_id(&db, &[("title", Value::from(format!("post {index}")))])
            .await
            .unwrap();
    }

    // Page numbers: familiar, and it knows the total.
    let second = db.table(&table).paginate(&db, 2, 10).await.unwrap();
    assert_eq!(second.total, 25);
    assert_eq!(second.rows.len(), 10);
    assert_eq!(second.last_page(), 3);
    assert_eq!(second.from(), Some(11));
    assert!(second.has_more());

    let last = db.table(&table).paginate(&db, 3, 10).await.unwrap();
    assert_eq!(last.rows.len(), 5);
    assert!(!last.has_more());

    // Cursors: no count, no offset, and stable while rows are inserted.
    let mut seen = 0;
    let mut cursor: Option<String> = None;
    loop {
        let page = db
            .table(&table)
            .cursor_paginate(&db, "id", cursor.as_deref(), 10)
            .await
            .unwrap();
        seen += page.rows.len();

        match page.next_cursor {
            Some(next) => cursor = Some(next),
            None => break,
        }
    }
    assert_eq!(seen, 25);
}