use renox::db::relations::{Morph, Pivot, belongs_to, count_many, has_many, sum_many};
use renox::db::{Migration, Ulid};
use renox::prelude::*;
use renox::testing::TestApp;
const SCHEMA: Migration = Migration::new("20300101000000_keys", "", None).sqlite("CREATE TABLE customers (id TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE invoices (id TEXT PRIMARY KEY, customer_id TEXT REFERENCES customers (id),
number TEXT NOT NULL, total INTEGER NOT NULL, created_at TEXT, updated_at TEXT, deleted_at TEXT);
CREATE TABLE lines (id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_id TEXT NOT NULL REFERENCES invoices (id),
amount INTEGER NOT NULL);
CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
CREATE TABLE invoice_tags (invoice_id TEXT NOT NULL, tag_id INTEGER NOT NULL, note TEXT NOT NULL DEFAULT '',
UNIQUE (invoice_id, tag_id));
CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, noteable_type TEXT NOT NULL,
noteable_id TEXT NOT NULL, body TEXT NOT NULL);
CREATE TABLE accounts (id BLOB PRIMARY KEY, name TEXT NOT NULL);", None).postgres("CREATE TABLE customers (id TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE invoices (id TEXT PRIMARY KEY, customer_id TEXT REFERENCES customers (id),
number TEXT NOT NULL, total BIGINT NOT NULL, created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ);
CREATE TABLE lines (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
invoice_id TEXT NOT NULL REFERENCES invoices (id), amount BIGINT NOT NULL);
CREATE TABLE tags (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE invoice_tags (invoice_id TEXT NOT NULL, tag_id BIGINT NOT NULL, note TEXT NOT NULL DEFAULT '',
UNIQUE (invoice_id, tag_id));
CREATE TABLE notes (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, noteable_type TEXT NOT NULL,
noteable_id TEXT NOT NULL, body TEXT NOT NULL);
CREATE TABLE accounts (id UUID PRIMARY KEY, name TEXT NOT NULL);", None);
#[derive(Model, serde::Serialize, Default, Debug, Clone, PartialEq)]
#[model(table = "customers")]
struct Customer {
id: String,
name: String,
}
#[derive(Model, serde::Serialize, Default, Debug, Clone, PartialEq)]
#[model(table = "invoices", soft_deletes)]
struct Invoice {
id: Ulid,
customer_id: Option<String>,
number: String,
total: i64,
created_at: Option<DateTime>,
updated_at: Option<DateTime>,
deleted_at: Option<DateTime>,
}
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "lines")]
struct Line {
id: i64,
invoice_id: Ulid,
amount: i64,
}
#[derive(Model, serde::Serialize, Default, Debug, Clone, PartialEq)]
#[model(table = "tags")]
struct Tag {
id: i64,
name: String,
}
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "notes")]
struct Note {
id: i64,
noteable_type: String,
noteable_id: Ulid,
body: String,
}
#[derive(FromRow)]
struct Tagging {
note: String,
}
const INVOICE_TAGS: Pivot<Ulid, i64> = Pivot::new("invoice_tags", "invoice_id", "tag_id");
const NOTEABLE: Morph = Morph::new("noteable_type", "noteable_id");
async fn app() -> TestApp {
TestApp::new(App::new().migrations(&[SCHEMA])).await
}
async fn invoice(db: &Db, number: &str, total: i64, customer: Option<&str>) -> Invoice {
Invoice::create(
db,
Invoice {
number: number.into(),
total,
customer_id: customer.map(Into::into),
..Default::default()
},
)
.await
.unwrap()
}
#[renox::test]
async fn ulid_keys_are_made_on_insert() {
let app = app().await;
let db = app.db();
let first = invoice(db, "INV-1", 10_000, None).await;
assert_eq!(first.id.as_str().len(), 26);
assert!(first.created_at.is_some());
let mut second = Invoice {
number: "INV-2".into(),
total: 5_000,
..Default::default()
};
second.save(db).await.unwrap();
assert!(second.id > first.id, "ULIDs sort by creation");
let mut found = Invoice::find_or_404(db, first.id.clone()).await.unwrap();
assert_eq!(found, first);
found.total = 12_000;
found.save(db).await.unwrap();
assert_eq!(
Invoice::find_or_404(db, first.id.clone())
.await
.unwrap()
.total,
12_000
);
let both = Invoice::find_many(db, [second.id.clone(), first.id.clone()])
.await
.unwrap();
assert_eq!(both.len(), 2);
assert_eq!(both[0].id, first.id, "in key order");
assert!(Invoice::find(db, Ulid::new()).await.unwrap().is_none());
found.delete(db).await.unwrap();
assert!(Invoice::find(db, first.id.clone()).await.unwrap().is_none());
found.refresh(db).await.unwrap();
assert!(found.deleted_at.is_some());
found.restore(db).await.unwrap();
assert!(Invoice::find(db, first.id).await.unwrap().is_some());
}
#[renox::test]
async fn string_keys_are_set_by_the_app() {
let app = app().await;
let db = app.db();
let acme = Customer::create(
db,
Customer {
id: "ACME".into(),
name: "Acme".into(),
},
)
.await
.unwrap();
assert_eq!(acme.id, "ACME");
let mut nameless = Customer {
name: "No code".into(),
..Default::default()
};
let err = nameless.save(db).await.unwrap_err();
assert!(format!("{err:?}").contains("set the `id`"), "{err:?}");
let mut found = Customer::find_or_404(db, "ACME".into()).await.unwrap();
found.name = "Acme Corp".into();
found.save(db).await.unwrap();
let mut globex = Customer {
id: "GLOBEX".into(),
name: "Globex".into(),
};
globex.insert(db).await.unwrap();
assert_eq!(Customer::all(db).await.unwrap().len(), 2);
let mut ghost = Customer {
id: "GHOST".into(),
name: "Ghost".into(),
};
assert!(matches!(ghost.save(db).await, Err(Error::NotFound)));
assert!(globex.insert(db).await.is_err());
}
#[renox::test]
async fn integer_models_created_with_an_id_keep_it() {
let app = app().await;
let db = app.db();
let tag = Tag::create(
db,
Tag {
id: 42,
name: "Imported".into(),
},
)
.await
.unwrap();
assert_eq!(tag.id, 42);
assert_eq!(Tag::find_or_404(db, 42).await.unwrap(), tag);
let next = Tag::create(
db,
Tag {
name: "New".into(),
..Default::default()
},
)
.await
.unwrap();
assert_ne!(next.id, 0);
}
#[renox::test]
async fn relations_follow_the_key_type() {
let app = app().await;
let db = app.db();
for (code, name) in [("ACME", "Acme"), ("GLOBEX", "Globex")] {
let customer = Customer {
id: code.into(),
name: name.into(),
};
Customer::create(db, customer).await.unwrap();
}
let a = invoice(db, "INV-1", 10_000, Some("ACME")).await;
let b = invoice(db, "INV-2", 5_000, None).await;
for (invoice_id, amount) in [(&a.id, 6_000), (&a.id, 4_000), (&b.id, 5_000)] {
let line = Line {
invoice_id: invoice_id.clone(),
amount,
..Default::default()
};
Line::create(db, line).await.unwrap();
}
let invoices = vec![a.clone(), b.clone()];
let customers = belongs_to::<Customer, _, _>(db, &invoices, |i| i.customer_id.clone())
.await
.unwrap();
assert_eq!(customers.len(), 1);
assert_eq!(customers["ACME"].name, "Acme");
let lines = has_many(db, &invoices, Line::query(), "invoice_id", |l| {
l.invoice_id.clone()
})
.await
.unwrap();
assert_eq!(lines[&a.id].len(), 2);
assert_eq!(lines[&b.id].len(), 1);
let counts = count_many(db, &invoices, Line::query(), "invoice_id")
.await
.unwrap();
assert_eq!((counts[&a.id], counts[&b.id]), (2, 1));
let sums = sum_many::<i64, _, _>(db, &invoices, Line::query(), "invoice_id", "amount")
.await
.unwrap();
assert_eq!(sums[&a.id], 10_000);
let mut tags = Vec::new();
for name in ["urgent", "paid", "late"] {
let tag = Tag {
name: name.into(),
..Default::default()
};
tags.push(Tag::create(db, tag).await.unwrap());
}
let (urgent, paid, late) = (tags[0].id, tags[1].id, tags[2].id);
INVOICE_TAGS
.attach(db, a.id.clone(), [urgent, paid])
.await
.unwrap();
INVOICE_TAGS
.attach_with(db, b.id.clone(), late, &[("note", &"since May")])
.await
.unwrap();
assert_eq!(
INVOICE_TAGS.ids(db, a.id.clone()).await.unwrap(),
[urgent, paid]
);
let loaded = INVOICE_TAGS
.load_for::<Tag, _>(db, &invoices)
.await
.unwrap();
assert_eq!(loaded[&a.id].len(), 2);
let with_pivot = INVOICE_TAGS
.load_with_pivot::<Tag, Tagging>(db, [b.id.clone()])
.await
.unwrap();
assert_eq!(with_pivot[&b.id][0].1.note, "since May");
let by_tag = INVOICE_TAGS
.inverse()
.load::<Invoice>(db, [urgent])
.await
.unwrap();
assert_eq!(by_tag[&urgent][0].id, a.id);
INVOICE_TAGS
.sync(db, a.id.clone(), [paid, late])
.await
.unwrap();
assert_eq!(
INVOICE_TAGS.ids(db, a.id.clone()).await.unwrap(),
[paid, late]
);
let (attached, detached) = INVOICE_TAGS
.toggle(db, a.id.clone(), [late, urgent])
.await
.unwrap();
assert_eq!((attached, detached), (vec![urgent], vec![late]));
for (parent, body) in [(&a, "Called them"), (&a, "Sent a reminder"), (&b, "Paid")] {
let note = Note {
noteable_type: Invoice::TABLE.into(),
noteable_id: parent.id.clone(),
body: body.into(),
..Default::default()
};
Note::create(db, note).await.unwrap();
}
assert_eq!(NOTEABLE.of(&a, Note::query()).count(db).await.unwrap(), 2);
let notes = NOTEABLE
.load_many(db, &invoices, Note::query(), |n| n.noteable_id.clone())
.await
.unwrap();
assert_eq!(notes[&b.id][0].body, "Paid");
let note_counts = NOTEABLE
.count_many(db, &invoices, Note::query())
.await
.unwrap();
assert_eq!(note_counts[&a.id], 2);
let all_notes = Note::all(db).await.unwrap();
let parents = NOTEABLE
.parents::<Invoice, _>(db, &all_notes, |n| {
(n.noteable_type.clone(), n.noteable_id.clone())
})
.await
.unwrap();
assert_eq!(parents.len(), 2);
}
#[renox::test]
async fn chunks_and_cursor_pages_follow_ulid_order() {
let app = app().await;
let db = app.db();
let mut made = Vec::new();
for n in 1..=25 {
made.push(invoice(db, &format!("INV-{n:02}"), n, None).await.id);
}
let mut seen = Vec::new();
let total = Invoice::query()
.chunk(db, 10, |rows| {
seen.extend(rows.into_iter().map(|i| i.id));
async { Ok(()) }
})
.await
.unwrap();
assert_eq!(total, 25);
assert_eq!(seen, made, "every row once, in key order");
let first = Invoice::query()
.cursor_paginate(db, None, 20)
.await
.unwrap();
assert_eq!(first.items[0].number, "INV-25");
let cursor = first.next_cursor.clone().unwrap();
assert_eq!(cursor, first.items[19].id.to_string());
let second = Invoice::query()
.cursor_paginate(db, Some(&cursor), 20)
.await
.unwrap();
assert_eq!(second.items.len(), 5);
assert_eq!(second.items[4].number, "INV-01");
assert!(second.next_cursor.is_none());
assert!(matches!(
Invoice::query().cursor_paginate(db, Some("nope"), 20).await,
Err(Error::BadRequest(_))
));
}
#[renox::test]
async fn many_rows_at_once_get_their_keys() {
let app = app().await;
let db = app.db();
let batch: Vec<Invoice> = (1..=3)
.map(|n| Invoice {
number: format!("B-{n}"),
total: n,
..Default::default()
})
.collect();
assert_eq!(Invoice::insert_many(db, batch).await.unwrap(), 3);
let stored = Invoice::query().order_by("id").get(db).await.unwrap();
assert_eq!(stored.len(), 3);
assert!(stored.iter().all(|i| i.id.as_str().len() == 26));
assert_eq!(stored[0].number, "B-1", "made in order");
let customers = vec![
Customer {
id: "A".into(),
name: "Acme".into(),
},
Customer {
id: "B".into(),
name: "Bolt".into(),
},
];
Customer::insert_many(db, customers).await.unwrap();
assert_eq!(
Customer::find_or_404(db, "B".into()).await.unwrap().name,
"Bolt"
);
let keyless = vec![Customer {
name: "Nameless".into(),
..Default::default()
}];
assert!(Customer::insert_many(db, keyless).await.is_err());
let feed = vec![
Customer {
id: "A".into(),
name: "Acme Corp".into(),
},
Customer {
id: "C".into(),
name: "Cargo".into(),
},
];
Customer::upsert(db, feed, &["id"], &["name"])
.await
.unwrap();
let all = Customer::all(db).await.unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].name, "Acme Corp", "updated by its key");
}
#[derive(serde::Deserialize)]
struct InvoiceForm {
number: String,
#[serde(skip)]
editing: Option<Ulid>,
}
impl Validate for InvoiceForm {
fn rules(&self, v: &mut Validator) {
let number = v.field("number", &self.number).unique("invoices", "number");
if let Some(id) = &self.editing {
number.ignore(id.clone());
}
}
}
#[renox::test]
async fn unique_ignores_a_ulid_row() {
let app = app().await;
let db = app.db();
let own = invoice(db, "INV-1", 1, None).await;
let clean = |form: InvoiceForm| {
let db = db.clone();
async move {
Validator::rules_of(&form)
.finish(&db)
.await
.unwrap()
.is_empty()
}
};
let taken = InvoiceForm {
number: "INV-1".into(),
editing: None,
};
assert!(!clean(taken).await);
let itself = InvoiceForm {
number: "INV-1".into(),
editing: Some(own.id.clone()),
};
assert!(clean(itself).await);
}
struct Invoices;
impl Module for Invoices {
fn name(&self) -> &'static str {
"invoices"
}
fn routes(&self) -> Routes {
Routes::new().get(
"/invoices/{id}",
|State(db): State<Db>, Path(id): Path<Ulid>| async move {
Ok::<_, Error>(Json(Invoice::find_or_404(&db, id).await?))
},
)
}
}
#[renox::test]
async fn ulids_come_from_paths_and_go_out_as_text() {
let app = TestApp::new(App::new().migrations(&[SCHEMA]).module(Invoices)).await;
let made = invoice(app.db(), "INV-1", 10_000, None).await;
app.get(&format!("/invoices/{}", made.id))
.await
.assert_ok()
.assert_json_path("id", made.id.to_string())
.assert_json_path("number", "INV-1");
app.get(&format!("/invoices/{}", made.id.as_str().to_lowercase()))
.await
.assert_ok();
app.get(&format!("/invoices/{}", Ulid::new()))
.await
.assert_not_found();
app.get("/invoices/not-a-ulid").await.assert_not_found();
}
#[cfg(feature = "uuid")]
mod uuids {
use super::*;
use renox::uuid::Uuid;
#[derive(Model, serde::Serialize, Default, Debug, Clone, PartialEq)]
#[model(table = "accounts")]
struct Account {
id: Uuid,
name: String,
}
#[renox::test]
async fn uuid_keys_are_version_7() {
let app = app().await;
let db = app.db();
let first = Account::create(
db,
Account {
name: "Cash".into(),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(first.id.get_version_num(), 7);
let mut second = Account {
name: "Bank".into(),
..Default::default()
};
second.save(db).await.unwrap();
assert!(second.id > first.id);
let mut found = Account::find_or_404(db, first.id).await.unwrap();
found.name = "Petty cash".into();
found.save(db).await.unwrap();
assert_eq!(
Account::find_or_404(db, first.id).await.unwrap().name,
"Petty cash"
);
found.delete(db).await.unwrap();
assert_eq!(Account::all(db).await.unwrap(), [second]);
}
}