use renox::db::ModelHooks;
use renox::db::relations::{Morph, Pivot};
use renox::prelude::*;
use renox::testing::TestApp;
#[derive(Model, serde::Serialize, Default, Clone, Debug)]
#[model(table = "posts", hooks)]
struct Post {
id: i64,
title: String,
slug: String,
views: i64,
}
impl ModelHooks for Post {
fn saving(&mut self, _creating: bool) -> Result {
if self.title.is_empty() {
return Err(abort(
StatusCode::UNPROCESSABLE_ENTITY,
"a post needs a title",
));
}
self.slug = self.title.to_lowercase().replace(' ', "-");
Ok(())
}
async fn saved(&self, created: bool) -> Result {
if let Some(state) = renox::context::app() {
let what = if created { "created" } else { "updated" };
state
.cache
.put("post.saved", &format!("{what} {}", self.id), None)
.await?;
}
Ok(())
}
fn deleting(&self) -> Result {
if self.title == "Pinned" {
return Err(abort(StatusCode::FORBIDDEN, "pinned posts stay"));
}
Ok(())
}
async fn deleted(&self) -> Result {
if let Some(state) = renox::context::app() {
state.cache.put("post.deleted", &self.id, None).await?;
}
Ok(())
}
}
#[derive(Model, serde::Serialize, Default, Clone, Debug)]
#[model(table = "videos")]
struct Video {
id: i64,
title: String,
}
#[derive(Model, serde::Serialize, Default, Clone, Debug)]
#[model(table = "comments")]
struct Comment {
id: i64,
commentable_type: String,
commentable_id: i64,
body: String,
}
#[derive(Model, serde::Serialize, Default, Clone, Debug)]
#[model(table = "tags")]
struct Tag {
id: i64,
name: String,
}
#[derive(FromRow, Debug)]
struct Tagging {
added_by: String,
created_at: Option<DateTime>,
updated_at: Option<DateTime>,
}
const POST_TAGS: Pivot = Pivot::new("post_tags", "post_id", "tag_id").with_timestamps();
const COMMENTABLE: Morph = Morph::new("commentable_type", "commentable_id");
struct Blog;
impl Module for Blog {
fn name(&self) -> &'static str {
"blog"
}
fn routes(&self) -> Routes {
Routes::new()
.post("/posts", |State(state): State<AppState>| async move {
let post = Post::create(
&state.db,
Post {
title: "Hello World".into(),
..Default::default()
},
)
.await?;
Ok::<_, Error>(post.slug)
})
.delete(
"/posts/{id}",
|State(state): State<AppState>, Path(id): Path<i64>| async move {
let mut post = Post::find_or_404(&state.db, id).await?;
post.delete(&state.db).await?;
Ok::<_, Error>("gone")
},
)
}
}
async fn blog() -> TestApp {
let app = TestApp::new(App::new().module(Blog)).await;
let (id, at) = match app.db().dialect() {
renox::db::Dialect::Postgres => (
"BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY",
"TIMESTAMPTZ",
),
_ => ("INTEGER PRIMARY KEY", "TEXT"),
};
for statement in [
format!(
"CREATE TABLE posts (id {id}, title TEXT NOT NULL, slug TEXT NOT NULL, \
views BIGINT NOT NULL)"
),
format!("CREATE TABLE videos (id {id}, title TEXT NOT NULL)"),
format!(
"CREATE TABLE comments (id {id}, commentable_type TEXT NOT NULL, \
commentable_id BIGINT NOT NULL, body TEXT NOT NULL)"
),
format!("CREATE TABLE tags (id {id}, name TEXT NOT NULL)"),
format!(
"CREATE TABLE post_tags (post_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, \
added_by TEXT NOT NULL DEFAULT 'system', created_at {at}, updated_at {at}, \
UNIQUE (post_id, tag_id))"
),
] {
renox::db::sql(statement).execute(app.db()).await.unwrap();
}
app
}
#[renox::test]
async fn hooks_run_around_saves_and_deletes() {
let app = blog().await;
let db = app.db();
let cache = &app.state().cache;
app.post("/posts", &[])
.await
.assert_status(200)
.assert_see("hello-world");
assert_eq!(
cache.get::<String>("post.saved").await.unwrap().as_deref(),
Some("created 1")
);
let mut post = Post::find_or_404(db, 1).await.unwrap();
post.title = "Second Title".into();
post.save(db).await.unwrap();
assert_eq!(Post::find_or_404(db, 1).await.unwrap().slug, "second-title");
post.title.clear();
assert!(post.save(db).await.is_err());
assert!(Post::create(db, Post::default()).await.is_err());
assert_eq!(Post::query().count(db).await.unwrap(), 1);
let mut pinned = Post::create(
db,
Post {
title: "Pinned".into(),
..Default::default()
},
)
.await
.unwrap();
assert!(pinned.delete(db).await.is_err());
assert!(Post::find(db, pinned.id).await.unwrap().is_some());
app.delete("/posts/1").await.assert_status(200);
assert_eq!(cache.get::<i64>("post.deleted").await.unwrap(), Some(1));
Post::where_eq("id", pinned.id).delete(db).await.unwrap();
assert!(Post::find(db, pinned.id).await.unwrap().is_none());
}
#[renox::test]
async fn partial_saves_keep_other_columns() {
let app = blog().await;
let db = app.db();
let original = Post::create(
db,
Post {
title: "A".into(),
..Default::default()
},
)
.await
.unwrap();
Post::where_eq("id", original.id)
.increment(db, "views", 3)
.await
.unwrap();
let mut post = original.clone();
post.title = "B".into();
post.save_only(db, &["title"]).await.unwrap();
let stored = Post::find_or_404(db, post.id).await.unwrap();
assert_eq!((stored.title.as_str(), stored.views), ("B", 3));
assert_eq!(stored.slug, "a", "`saving` ran, but `slug` wasn't listed");
let mut post = stored.clone();
assert!(
post.save_changes(db, &stored).await.unwrap(),
"the hook fixed the slug"
);
let stored = Post::find_or_404(db, post.id).await.unwrap();
assert_eq!(stored.slug, "b");
let mut post = stored.clone();
assert!(
!post.save_changes(db, &stored).await.unwrap(),
"nothing changed"
);
post.title = "C D".into();
Post::where_eq("id", post.id)
.increment(db, "views", 1)
.await
.unwrap();
assert!(post.save_changes(db, &stored).await.unwrap());
let stored = Post::find_or_404(db, post.id).await.unwrap();
assert_eq!(
(stored.title.as_str(), stored.slug.as_str(), stored.views),
("C D", "c-d", 4),
"the slug the hook changed is saved too; views untouched"
);
assert!(post.save_only(db, &["nope"]).await.is_err());
assert!(post.save_only(db, &["id"]).await.is_err());
assert!(Post::default().save_only(db, &["title"]).await.is_err());
}
#[renox::test]
async fn encrypts_with_the_app_key() {
let app = blog().await;
let state = app.state();
let sealed = state.encrypt("sk_live_123");
assert!(!sealed.contains("sk_live"));
assert_ne!(
sealed,
state.encrypt("sk_live_123"),
"a fresh nonce each time"
);
assert_eq!(state.decrypt(&sealed).unwrap(), "sk_live_123");
let mut tampered = sealed.into_bytes();
let last = tampered.len() - 3;
tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
assert!(
state
.decrypt(&String::from_utf8(tampered).unwrap())
.is_err()
);
assert!(state.decrypt("not even base64").is_err());
}
#[renox::test]
async fn pivots_carry_data() {
let app = blog().await;
let db = app.db();
for name in ["rust", "web", "db"] {
Tag::create(
db,
Tag {
name: name.into(),
..Default::default()
},
)
.await
.unwrap();
}
assert!(
POST_TAGS
.attach_with(db, 1, 1, &[("added_by", &"ana")])
.await
.unwrap()
);
assert!(
!POST_TAGS
.attach_with(db, 1, 1, &[("added_by", &"bo")])
.await
.unwrap()
);
POST_TAGS.attach(db, 1, [2]).await.unwrap();
assert!(
POST_TAGS
.update_pivot(db, 1, 2, &[("added_by", &"cy")])
.await
.unwrap()
);
assert!(
!POST_TAGS
.update_pivot(db, 1, 3, &[("added_by", &"cy")])
.await
.unwrap()
);
let tags = POST_TAGS
.load_with_pivot::<Tag, Tagging>(db, [1, 2])
.await
.unwrap();
let mut seen: Vec<(String, String)> = tags[&1]
.iter()
.map(|(tag, pivot)| (tag.name.clone(), pivot.added_by.clone()))
.collect();
seen.sort();
assert_eq!(
seen,
[("rust".into(), "ana".into()), ("web".into(), "cy".into())]
);
assert!(
tags[&1]
.iter()
.all(|(_, p)| p.created_at.is_some() && p.updated_at.is_some())
);
assert!(!tags.contains_key(&2));
let (attached, detached) = POST_TAGS.toggle(db, 1, [2, 3]).await.unwrap();
assert_eq!((attached, detached), (vec![3], vec![2]));
assert_eq!(POST_TAGS.ids(db, 1).await.unwrap(), [1, 3]);
assert_eq!(POST_TAGS.inverse().ids(db, 3).await.unwrap(), [1]);
}
#[renox::test]
async fn polymorphic_children_and_parents() {
let app = blog().await;
let db = app.db();
let post = Post::create(
db,
Post {
title: "P".into(),
..Default::default()
},
)
.await
.unwrap();
let video = Video::create(
db,
Video {
title: "V".into(),
..Default::default()
},
)
.await
.unwrap();
assert_eq!((post.id, video.id), (1, 1), "same ids, different tables");
for (kind, id, body) in [
(Post::TABLE, post.id, "on post"),
(Video::TABLE, video.id, "on video"),
(Video::TABLE, video.id, "also on video"),
] {
Comment::create(
db,
Comment {
commentable_type: kind.into(),
commentable_id: id,
body: body.into(),
..Default::default()
},
)
.await
.unwrap();
}
assert_eq!(
COMMENTABLE
.of(&post, Comment::query())
.count(db)
.await
.unwrap(),
1
);
assert_eq!(
COMMENTABLE
.of(&video, Comment::query())
.count(db)
.await
.unwrap(),
2
);
let on_videos = COMMENTABLE
.load_many(
db,
std::slice::from_ref(&video),
Comment::query().order_by("id"),
|c| c.commentable_id,
)
.await
.unwrap();
let bodies: Vec<&str> = on_videos[&1].iter().map(|c| c.body.as_str()).collect();
assert_eq!(bodies, ["on video", "also on video"]);
let comments = Comment::query().order_by("id").get(db).await.unwrap();
let parent = |c: &Comment| (c.commentable_type.clone(), c.commentable_id);
let posts = COMMENTABLE
.parents::<Post, _>(db, &comments, parent)
.await
.unwrap();
let videos = COMMENTABLE
.parents::<Video, _>(db, &comments, parent)
.await
.unwrap();
assert_eq!((posts.len(), posts[&1].title.as_str()), (1, "P"));
assert_eq!((videos.len(), videos[&1].title.as_str()), (1, "V"));
}
#[derive(Model, Default, Clone, Debug)]
#[model(table = "stamped")]
struct Stamped {
id: i64,
name: String,
created_at: DateTime,
updated_at: DateTime,
}
#[renox::test]
async fn plain_timestamps_are_filled_on_insert_and_update() {
let app = TestApp::new(App::new()).await;
let db = app.db();
let (id, at) = match db.dialect() {
renox::db::Dialect::Postgres => (
"BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY",
"TIMESTAMPTZ",
),
_ => ("INTEGER PRIMARY KEY", "TEXT"),
};
renox::db::sql(format!(
"CREATE TABLE stamped (id {id}, name TEXT NOT NULL, created_at {at} NOT NULL, \
updated_at {at} NOT NULL)"
))
.execute(db)
.await
.unwrap();
let mut row = Stamped::create(
db,
Stamped {
name: "first".into(),
..Default::default()
},
)
.await
.unwrap();
assert!(
row.created_at > DateTime::default(),
"created_at is filled on insert"
);
assert_eq!(row.created_at, row.updated_at);
let created = row.created_at;
app.travel(std::time::Duration::from_secs(90));
app.at_travelled_time(async {
row.name = "second".into();
row.save(db).await.unwrap();
})
.await;
assert_eq!(row.created_at, created, "created_at stays");
assert!(row.updated_at > created, "updated_at moves on");
let read = Stamped::find(db, row.id).await.unwrap().unwrap();
assert_eq!(
(read.created_at, read.updated_at),
(row.created_at, row.updated_at)
);
}