diff --git a/.gitignore b/.gitignore index 4e30131..7c35d48 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Cargo.lock bin/ pkg/ wasm-pack.log +.env diff --git a/Cargo.toml b/Cargo.toml index 46f0e0b..756eae7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,10 @@ edition = "2018" [dependencies] chrono = { version = "0.4.31", features = [ "clock", "serde" ] } +diesel = { version = "2.1.4", features = ["postgres", "chrono" ] } +dotenvy = "0.15" serde = { version = "1.0", features = [ "derive" ] } serde_json = { version = "1.0", features = [ "std" ] } serde_with = { version = "3.4.0", features = [ "std", "chrono_0_4", "json" ] } + +rocket = { version = "0.5", features = [ "json" ] } diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..597976b --- /dev/null +++ b/compose.yml @@ -0,0 +1,27 @@ +services: + db: + image: postgres + #volumes: + #- ./dbdata:/var/lib/postgresql/data + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_USER: ${POSTGRES_USERNAME} + POSTGRES_DB: ${POSTGRES_DB} + healthcheck: + test: [ "CMD", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 + + pgweb: + image: sosedoff/pgweb + ports: + - 8081:8081 + environment: + PGWEB_DATABASE_URL: postgres://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}?sslmode=disable + depends_on: + db: + condition: service_healthy + restart: true diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..c028f4a --- /dev/null +++ b/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId"] + +[migrations_directory] +dir = "migrations" diff --git a/migrations/.keep b/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/2023-12-22-135046_create_users/down.sql b/migrations/2023-12-22-135046_create_users/down.sql new file mode 100644 index 0000000..cc1f647 --- /dev/null +++ b/migrations/2023-12-22-135046_create_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users; diff --git a/migrations/2023-12-22-135046_create_users/up.sql b/migrations/2023-12-22-135046_create_users/up.sql new file mode 100644 index 0000000..5ef1fe7 --- /dev/null +++ b/migrations/2023-12-22-135046_create_users/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + email VARCHAR NOT NULL +); diff --git a/migrations/2023-12-22-135650_create_categories/down.sql b/migrations/2023-12-22-135650_create_categories/down.sql new file mode 100644 index 0000000..e8f3f99 --- /dev/null +++ b/migrations/2023-12-22-135650_create_categories/down.sql @@ -0,0 +1 @@ +DROP TABLE categories; diff --git a/migrations/2023-12-22-135650_create_categories/up.sql b/migrations/2023-12-22-135650_create_categories/up.sql new file mode 100644 index 0000000..c993778 --- /dev/null +++ b/migrations/2023-12-22-135650_create_categories/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + user_id SERIAL REFERENCES users(id) +); diff --git a/migrations/2023-12-22-135844_create_series/down.sql b/migrations/2023-12-22-135844_create_series/down.sql new file mode 100644 index 0000000..6a07078 --- /dev/null +++ b/migrations/2023-12-22-135844_create_series/down.sql @@ -0,0 +1 @@ +DROP TABLE series; diff --git a/migrations/2023-12-22-135844_create_series/up.sql b/migrations/2023-12-22-135844_create_series/up.sql new file mode 100644 index 0000000..ece3f4a --- /dev/null +++ b/migrations/2023-12-22-135844_create_series/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE series ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + repeat INTEGER NOT NULL, + good BOOLEAN NOT NULL, + category_id SERIAL REFERENCES categories(id) +); diff --git a/migrations/2023-12-22-141631_create_series_points/down.sql b/migrations/2023-12-22-141631_create_series_points/down.sql new file mode 100644 index 0000000..bd3bbca --- /dev/null +++ b/migrations/2023-12-22-141631_create_series_points/down.sql @@ -0,0 +1 @@ +DROP TABLE series_points; diff --git a/migrations/2023-12-22-141631_create_series_points/up.sql b/migrations/2023-12-22-141631_create_series_points/up.sql new file mode 100644 index 0000000..595e6af --- /dev/null +++ b/migrations/2023-12-22-141631_create_series_points/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE series_points ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, + value INTEGER NOT NULL, + series_id SERIAL REFERENCES series(id) +); diff --git a/readme.md b/readme.md index cb49133..c5cf12d 100644 --- a/readme.md +++ b/readme.md @@ -4,10 +4,8 @@ ## Why -- Cross platform (just a web app) +- Simple - Flexible (tracks whatever) -- No sign up required -- Can sign up if you want - Self-hostable - CalDAV reminders and calendars to remind you to build habits - Rust :crab: :muscle: diff --git a/src/db.rs b/src/db.rs index ffef634..80bc5bb 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,5 @@ -pub mod json; +//pub mod json; +pub mod pg; use crate::models::*; use serde_json; @@ -9,6 +10,8 @@ pub enum Error { Generic(String), FsIo(String), Json(String), + PgDb(String), + Parsing(String), } impl From for Error { @@ -23,19 +26,22 @@ impl From for Error { } } +impl From for Error { + fn from(e: diesel::result::Error) -> Self { + Self::PgDb(e.to_string()) + } +} + pub trait Storage { + fn new() -> Self; fn add_category(&mut self, category: NewCategory) -> Result; - fn add_series(&mut self, category_id: i32, series: NewSeries) -> Result; - fn add_series_point( - &mut self, - category_id: i32, - series_id: i32, - series_point: NewSeriesPoint, - ) -> Result; - //fn add_user(&mut self, id: i32, user: NewUser) -> Result; - fn get_category(&self, id: i32) -> Option; - fn get_series(&self, category_id: i32, series_id: i32) -> Option; - //fn get_user(&self, id: i32) -> Option; + fn add_series(&mut self, series: NewSeries) -> Result; + fn add_series_point(&mut self, series_point: NewSeriesPoint) -> Result; + fn add_user(&mut self, user: NewUser) -> Result; + fn get_categories(&mut self, filter: CategoryFilter) -> Result, Error>; + fn get_series(&mut self, filter: SeriesFilter) -> Result, Error>; + fn get_series_points(&mut self, filter: SeriesPointFilter) -> Result, Error>; + fn get_users(&mut self, filter: UserFilter) -> Result, Error>; //fn update_category(&mut self, id: i32, changeset: CategoryChangeset) -> Result<(), Error>; //fn update_series(&mut self, id: i32, changeset: SeriesChangeset) -> Result<(), Error>; //fn update_user(&mut self, id: i32, changeset: UserChangeset) -> Result<(), Error>; diff --git a/src/db/json.rs b/src/db/json.rs index 4f80126..8a45c58 100644 --- a/src/db/json.rs +++ b/src/db/json.rs @@ -6,15 +6,6 @@ pub struct JsonDb { /// JsonDb is single user. impl JsonDb { - pub fn new(value: Option) -> Self { - match value { - Some(value) => JsonDb { value }, - None => JsonDb { - value: String::from("[]"), - }, - } - } - fn load(&self) -> Result, Error> { match serde_json::from_str::>(&self.value) { Ok(d) => Ok(d), @@ -35,6 +26,12 @@ impl JsonDb { } impl Storage for JsonDb { + fn new() -> Self { + JsonDb { + value: String::from("[]"), + } + } + fn add_category(&mut self, category: NewCategory) -> Result { let mut max_id = 1; let mut data = self.load()?; diff --git a/src/db/pg.rs b/src/db/pg.rs new file mode 100644 index 0000000..38261de --- /dev/null +++ b/src/db/pg.rs @@ -0,0 +1,181 @@ +use super::{Error, Storage}; +use crate::{models::*, schema}; +use diesel::pg::PgConnection; +use diesel::prelude::*; +use dotenvy::dotenv; +use std::env; + +pub struct PgDb { + conn: PgConnection, +} + +impl Storage for PgDb { + fn new() -> Self { + dotenv().ok(); + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + Self { + conn: PgConnection::establish(&database_url) + .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)), + } + } + + fn add_user(&mut self, user: NewUser) -> Result { + use schema::users; + let ret = diesel::insert_into(users::table) + .values(&user) + .returning(users::dsl::id) + .get_result(&mut self.conn); + + match ret { + Ok(val) => Ok(val), + Err(e) => Err(Error::from(e)), + } + } + + fn add_category(&mut self, category: NewCategory) -> Result { + use schema::categories; + let ret = diesel::insert_into(categories::table) + .values(&category) + .returning(categories::dsl::id) + .get_result(&mut self.conn); + + match ret { + Ok(val) => Ok(val), + Err(e) => Err(Error::from(e)), + } + } + + fn add_series(&mut self, series: NewSeries) -> Result { + use schema::series; + let ret = diesel::insert_into(series::table) + .values(&series) + .returning(series::dsl::id) + .get_result(&mut self.conn); + + match ret { + Ok(val) => Ok(val), + Err(e) => Err(Error::from(e)), + } + } + + fn add_series_point(&mut self, series_point: NewSeriesPoint) -> Result { + use schema::series_points; + let ret = diesel::insert_into(series_points::table) + .values(&series_point) + .returning(series_points::dsl::id) + .get_result(&mut self.conn); + + match ret { + Ok(val) => Ok(val), + Err(e) => Err(Error::from(e)), + } + } + + fn get_categories(&mut self, filter: CategoryFilter) -> Result, Error> { + use schema::categories; + + let mut query = categories::table.into_boxed(); + + if let Some(val) = filter.id { + query = query.filter(categories::id.eq(val)); + } + + if let Some(val) = filter.name { + query = query.filter(categories::name.eq(val)); + } + + if let Some(val) = filter.user_id { + query = query.filter(categories::user_id.eq(val)); + } + + match query.select(Category::as_select()).load(&mut self.conn) { + Ok(q) => Ok(q), + Err(e) => Err(Error::from(e)), + } + } + + fn get_series(&mut self, filter: SeriesFilter) -> Result, Error> { + use schema::series; + + let mut query = series::table.into_boxed(); + + if let Some(val) = filter.id { + query = query.filter(series::id.eq(val)); + } + + if let Some(val) = filter.name { + query = query.filter(series::name.eq(val)); + } + + if let Some(val) = filter.repeat { + query = query.filter(series::repeat.eq(val)); + } + + if let Some(val) = filter.good { + query = query.filter(series::good.eq(val)); + } + + if let Some(val) = filter.category_id { + query = query.filter(series::category_id.eq(val)); + } + + match query.select(Series::as_select()).load(&mut self.conn) { + Ok(q) => Ok(q), + Err(e) => Err(Error::from(e)), + } + } + + fn get_series_points(&mut self, filter: SeriesPointFilter) -> Result, Error> { + use schema::series_points; + + let mut query = series_points::table.into_boxed(); + + if let Some(val) = filter.id { + query = query.filter(series_points::id.eq(val)); + } + + if let Some(val) = filter.timestamp_millis { + match chrono::NaiveDateTime::from_timestamp_millis(val) { + Some(val) => query = query.filter(series_points::timestamp.eq(val)), + _ => return Err(Error::Parsing("Failed to parse timestamp".to_owned())), + } + } + + if let Some(val) = filter.value { + query = query.filter(series_points::value.eq(val)); + } + + if let Some(val) = filter.series_id { + query = query.filter(series_points::series_id.eq(val)); + } + + match query.select(SeriesPoint::as_select()).load(&mut self.conn) { + Ok(q) => Ok(q), + Err(e) => Err(Error::from(e)), + } + } + + fn get_users(&mut self, filter: UserFilter) -> Result, Error> { + use schema::users; + + let mut query = users::table.into_boxed(); + + if let Some(val) = filter.id { + query = query.filter(users::id.eq(val)); + } + + if let Some(val) = filter.name { + query = query.filter(users::name.eq(val)); + } + + if let Some(val) = filter.email { + query = query.filter(users::email.eq(val)); + } + + match query.select(User::as_select()).load(&mut self.conn) { + Ok(q) => Ok(q), + Err(e) => Err(Error::from(e)), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 83cdabe..4077277 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ pub mod db; pub mod models; +pub mod schema; diff --git a/src/models.rs b/src/models.rs index 35b6a22..7595921 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,11 +1,9 @@ mod category; mod series; mod series_point; -mod series_type; mod user; pub use category::*; pub use series::*; pub use series_point::*; -pub use series_type::*; pub use user::*; diff --git a/src/models/category.rs b/src/models/category.rs index c9b8895..b839c65 100644 --- a/src/models/category.rs +++ b/src/models/category.rs @@ -1,33 +1,34 @@ -use crate::models::Series; +use crate::schema::categories; +use diesel; +use diesel::prelude::*; +use rocket::form::FromForm; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable, PartialEq)] +#[diesel(table_name = categories)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct Category { pub id: i32, pub name: String, - pub series: Vec, + pub user_id: i32, } -impl Category { - pub fn new(id: i32, new: NewCategory) -> Category { - Category { - id, - name: new.name, - series: new.series, - } - } +impl Category {} - pub fn add_series(&mut self, series: Series) { - self.series.push(series); - } +#[derive(FromForm)] +pub struct CategoryFilter { + pub id: Option, + pub name: Option, + pub user_id: Option, } pub struct CategoryChangeset { pub name: Option, - pub series: Option>, } +#[derive(Insertable, Deserialize, FromForm)] +#[diesel(table_name = categories)] pub struct NewCategory { pub name: String, - pub series: Vec, + pub user_id: i32, } diff --git a/src/models/series.rs b/src/models/series.rs index 93ee94c..b086ed9 100644 --- a/src/models/series.rs +++ b/src/models/series.rs @@ -1,45 +1,45 @@ -use super::series_point::SeriesPoint; -use chrono; +use crate::schema::series; +use diesel; +use diesel::prelude::*; +use rocket::form::FromForm; use serde::{Deserialize, Serialize}; use serde_with; #[serde_with::serde_as] -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable, PartialEq)] +#[diesel(table_name = series)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct Series { pub id: i32, pub name: String, - #[serde_as(as = "serde_with::DurationSeconds")] - pub repeat: chrono::Duration, + pub repeat: i32, pub good: bool, - pub points: Vec, + pub category_id: i32, } -impl Series { - pub fn new(id: i32, series: NewSeries) -> Series { - Series { - id, - name: series.name, - repeat: series.repeat, - good: series.good, - points: series.points, - } - } - - pub fn add_point(&mut self, point: SeriesPoint) { - self.points.push(point); - } +#[derive(FromForm)] +pub struct SeriesFilter { + pub id: Option, + pub name: Option, + pub repeat: Option, + pub good: Option, + pub category_id: Option, } +#[derive(AsChangeset)] +#[diesel(table_name = series)] pub struct SeriesChangeset { pub name: Option, - pub repeat: Option, + pub repeat: Option, pub good: Option, - pub points: Option>, + pub category_id: Option, } +#[derive(Insertable, Deserialize)] +#[diesel(table_name = series)] pub struct NewSeries { pub name: String, - pub repeat: chrono::Duration, + pub repeat: i32, pub good: bool, - pub points: Vec, + pub category_id: i32, } diff --git a/src/models/series_point.rs b/src/models/series_point.rs index 4632363..68197be 100644 --- a/src/models/series_point.rs +++ b/src/models/series_point.rs @@ -1,25 +1,32 @@ -use super::series_type::SeriesType; +use crate::schema::series_points; use chrono; +use diesel; +use diesel::prelude::*; +use rocket::form::FromForm; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable, PartialEq)] +#[diesel(table_name = series_points)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct SeriesPoint { pub id: i32, pub timestamp: chrono::NaiveDateTime, - pub value: SeriesType, + pub value: i32, + pub series_id: i32, } -impl SeriesPoint { - pub fn new(id: i32, new: NewSeriesPoint) -> SeriesPoint { - SeriesPoint { - id, - timestamp: new.timestamp, - value: new.value, - } - } +#[derive(FromForm)] +pub struct SeriesPointFilter { + pub id: Option, + pub timestamp_millis: Option, + pub value: Option, + pub series_id: Option, } +#[derive(Insertable, Deserialize)] +#[diesel(table_name = series_points)] pub struct NewSeriesPoint { pub timestamp: chrono::NaiveDateTime, - pub value: SeriesType, + pub value: i32, + pub series_id: i32, } diff --git a/src/models/series_type.rs b/src/models/series_type.rs deleted file mode 100644 index 24b10fc..0000000 --- a/src/models/series_type.rs +++ /dev/null @@ -1,9 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize)] -pub enum SeriesType { - Bool(bool), - Count(u32), - Signed(i32), - Float(f32), -} diff --git a/src/models/user.rs b/src/models/user.rs index da1f48b..f8c34d0 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,17 +1,34 @@ +use crate::schema::users; +use diesel; +use diesel::prelude::*; +use rocket::form::FromForm; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable, PartialEq)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct User { pub id: i32, pub name: String, pub email: String, } +#[derive(FromForm)] +pub struct UserFilter { + pub id: Option, + pub name: Option, + pub email: Option, +} + +#[derive(AsChangeset)] +#[diesel(table_name = users)] pub struct UserChangeset { pub name: Option, pub email: Option, } +#[derive(Insertable, Deserialize)] +#[diesel(table_name = users)] pub struct NewUser { pub name: String, pub email: String, diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..16d9a15 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,47 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + categories (id) { + id -> Int4, + name -> Varchar, + user_id -> Int4, + } +} + +diesel::table! { + series (id) { + id -> Int4, + name -> Varchar, + repeat -> Int4, + good -> Bool, + category_id -> Int4, + } +} + +diesel::table! { + series_points (id) { + id -> Int4, + timestamp -> Timestamp, + value -> Int4, + series_id -> Int4, + } +} + +diesel::table! { + users (id) { + id -> Int4, + name -> Varchar, + email -> Varchar, + } +} + +diesel::joinable!(categories -> users (user_id)); +diesel::joinable!(series -> categories (category_id)); +diesel::joinable!(series_points -> series (series_id)); + +diesel::allow_tables_to_appear_in_same_query!( + categories, + series, + series_points, + users, +);