refactor: clean up db stuff; start comments stuff

image-uploads
Jef Roosens 2024-12-29 19:53:06 +01:00
parent f980115d45
commit fed9c01370
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
9 changed files with 174 additions and 118 deletions

108
src/db.rs
View File

@ -1,108 +0,0 @@
use std::{error::Error, fmt};
use r2d2_sqlite::{
rusqlite::{self, Row},
SqliteConnectionManager,
};
use serde::{Deserialize, Serialize};
pub type DbPool = r2d2::Pool<SqliteConnectionManager>;
#[derive(Debug)]
pub enum DbError {
Pool(r2d2::Error),
Db(rusqlite::Error),
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pool(_) => write!(f, "failed to acquire connection from pool"),
Self::Db(_) => write!(f, "error while accessing the database"),
}
}
}
impl Error for DbError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Pool(err) => Some(err),
Self::Db(err) => Some(err),
}
}
}
impl From<r2d2::Error> for DbError {
fn from(value: r2d2::Error) -> Self {
Self::Pool(value)
}
}
impl From<rusqlite::Error> for DbError {
fn from(value: rusqlite::Error) -> Self {
Self::Db(value)
}
}
#[derive(Serialize)]
pub struct Plant {
id: i32,
name: String,
species: String,
description: String,
}
impl Plant {
pub fn from_row(row: &Row<'_>) -> Result<Self, rusqlite::Error> {
Ok(Self {
id: row.get(0)?,
name: row.get(1)?,
species: row.get(2)?,
description: row.get(3)?,
})
}
}
#[derive(Deserialize)]
pub struct NewPlant {
name: String,
species: String,
description: String,
}
pub fn list_plants(pool: &DbPool) -> Result<Vec<Plant>, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare("select * from plants")?;
let mut plants = Vec::new();
for plant in stmt.query_map((), |row| Plant::from_row(row))? {
plants.push(plant?);
}
Ok(plants)
}
pub fn insert_plant(pool: &DbPool, plant: &NewPlant) -> Result<Plant, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"insert into plants (name, species, description) values ($1, $2, $3) returning *",
)?;
Ok(
stmt.query_row((&plant.name, &plant.species, &plant.description), |row| {
Plant::from_row(row)
})?,
)
}
pub fn get_plant(pool: &DbPool, id: u32) -> Result<Option<Plant>, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare("select * from plants where id = $1")?;
match stmt.query_row((id,), |row| Plant::from_row(row)) {
Ok(plant) => Ok(Some(plant)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(DbError::Db(err)),
}
}

19
src/db/comment.rs 100644
View File

@ -0,0 +1,19 @@
use r2d2_sqlite::rusqlite::{self, Row};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Comment {
id: i32,
plant_id: i32,
comment: String,
}
impl Comment {
pub fn from_row(row: &Row<'_>) -> Result<Self, rusqlite::Error> {
Ok(Self {
id: row.get(0)?,
plant_id: row.get(1)?,
comment: row.get(2)?,
})
}
}

47
src/db/mod.rs 100644
View File

@ -0,0 +1,47 @@
mod comment;
mod plant;
use r2d2_sqlite::{rusqlite, SqliteConnectionManager};
use std::{error::Error, fmt};
pub use comment::Comment;
pub use plant::{NewPlant, Plant};
pub type DbPool = r2d2::Pool<SqliteConnectionManager>;
#[derive(Debug)]
pub enum DbError {
Pool(r2d2::Error),
Db(rusqlite::Error),
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pool(_) => write!(f, "failed to acquire connection from pool"),
Self::Db(_) => write!(f, "error while accessing the database"),
}
}
}
impl Error for DbError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Pool(err) => Some(err),
Self::Db(err) => Some(err),
}
}
}
impl From<r2d2::Error> for DbError {
fn from(value: r2d2::Error) -> Self {
Self::Pool(value)
}
}
impl From<rusqlite::Error> for DbError {
fn from(value: rusqlite::Error) -> Self {
Self::Db(value)
}
}

73
src/db/plant.rs 100644
View File

@ -0,0 +1,73 @@
use r2d2_sqlite::rusqlite::{self, Row};
use serde::{Deserialize, Serialize};
use super::{Comment, DbError, DbPool};
#[derive(Serialize)]
pub struct Plant {
id: i32,
name: String,
species: String,
description: String,
}
impl Plant {
pub fn from_row(row: &Row<'_>) -> Result<Self, rusqlite::Error> {
Ok(Self {
id: row.get(0)?,
name: row.get(1)?,
species: row.get(2)?,
description: row.get(3)?,
})
}
pub fn all(pool: &DbPool) -> Result<Vec<Self>, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare("select * from plants")?;
let plants: Result<Vec<_>, _> = stmt.query_map((), Self::from_row)?.collect();
Ok(plants?)
}
pub fn with_id(pool: &DbPool, id: i32) -> Result<Option<Self>, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare("select * from plants where id = $1")?;
match stmt.query_row((id,), |row| Plant::from_row(row)) {
Ok(plant) => Ok(Some(plant)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(DbError::Db(err)),
}
}
pub fn comments(&self, pool: &DbPool) -> Result<Vec<Comment>, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare("select * from plant_comments where plant_id = $1")?;
let comments: Result<Vec<_>, _> = stmt.query_map((self.id,), Comment::from_row)?.collect();
Ok(comments?)
}
}
#[derive(Deserialize)]
pub struct NewPlant {
name: String,
species: String,
description: String,
}
impl NewPlant {
pub fn insert(self, pool: &DbPool) -> Result<Plant, DbError> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"insert into plants (name, species, description) values ($1, $2, $3) returning *",
)?;
Ok(stmt.query_row(
(&self.name, &self.species, &self.description),
Plant::from_row,
)?)
}
}

View File

@ -7,9 +7,10 @@ use r2d2_sqlite::{rusqlite, SqliteConnectionManager};
use tera::Tera;
use tower_http::compression::CompressionLayer;
const MIGRATIONS: [&str; 2] = [
const MIGRATIONS: [&str; 3] = [
include_str!("migrations/000_initial.sql"),
include_str!("migrations/001_plants.sql"),
include_str!("migrations/002_comments.sql"),
];
const STATIC_FILES: [(&str, &'static str); 1] = [(
"htmx_2.0.4.min.js",

View File

@ -0,0 +1,5 @@
create table comments (
id integer primary key,
plant_id integer references plants (id),
comment text not null
);

View File

@ -10,7 +10,7 @@ use axum::{
use tera::Context;
use tower_http::set_header::SetResponseHeaderLayer;
use crate::db;
use crate::db::Plant;
const HX_REQUEST_HEADER: &str = "HX-Request";
const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
@ -46,7 +46,7 @@ pub fn app(ctx: crate::Context) -> axum::Router {
}
async fn get_index(State(ctx): State<crate::Context>) -> Html<String> {
let plants = tokio::task::spawn_blocking(move || db::list_plants(&ctx.pool))
let plants = tokio::task::spawn_blocking(move || Plant::all(&ctx.pool))
.await
.unwrap()
.unwrap();

View File

@ -7,7 +7,7 @@ use axum::{
};
use tera::Context;
use crate::db;
use crate::db::{self, DbError, Plant};
use super::render_partial;
@ -21,15 +21,21 @@ pub fn app(ctx: crate::Context) -> axum::Router {
async fn get_plant_page(
State(ctx): State<crate::Context>,
headers: HeaderMap,
Path(plant_id): Path<u32>,
Path(plant_id): Path<i32>,
) -> Html<String> {
let plant = tokio::task::spawn_blocking(move || db::get_plant(&ctx.pool, plant_id))
.await
.unwrap()
.unwrap();
let (plant, comments) = tokio::task::spawn_blocking(move || {
let plant = Plant::with_id(&ctx.pool, plant_id)?.unwrap();
let comments = plant.comments(&ctx.pool)?;
Ok::<_, DbError>((plant, comments))
})
.await
.unwrap()
.unwrap();
let mut context = Context::new();
context.insert("plant", &plant);
context.insert("comments", &comments);
let tmpl = if render_partial(&headers) {
"partials/plant_info.html"
@ -44,7 +50,7 @@ async fn post_plant(
State(ctx): State<crate::Context>,
Form(plant): Form<db::NewPlant>,
) -> Html<String> {
let plant = tokio::task::spawn_blocking(move || db::insert_plant(&ctx.pool, &plant))
let plant = tokio::task::spawn_blocking(move || plant.insert(&ctx.pool))
.await
.unwrap()
.unwrap();

View File

@ -4,3 +4,16 @@
<p>{{ plant.species }}</p>
<h3>Description</h3>
<p>{{ plant.description }}</p>
<h3>Comments</h3>
<ul id="comments">
{% for comment in comments %}
<li>
{{ comment.comment }}
</li>
{% endfor %}
</ul>
<form hx-post="/plants/{{ plant.id }}/comments" hx-target="#comments" hx-swap="beforeend">
<label for="comment">Comment:</label>
<textarea id="comment" name="comment" rows=4></textarea></br>
<input type="submit">
</form>