Update database schema

Also bump major version to match schema version
This commit is contained in:
2020-11-09 17:44:28 +01:00
parent a91266b705
commit a85d43b35d
12 changed files with 247 additions and 41 deletions

2
Cargo.lock generated
View File

@ -1584,7 +1584,7 @@ checksum = "7345c971d1ef21ffdbd103a75990a15eb03604fc8b8852ca8cb418ee1a099028"
[[package]] [[package]]
name = "stl" name = "stl"
version = "0.2.0" version = "2.0.0"
dependencies = [ dependencies = [
"bincode", "bincode",
"chrono", "chrono",

View File

@ -1,7 +1,7 @@
[package] [package]
name = "stl" name = "stl"
description = "studielogg aka scuffed toggl" description = "studielogg aka scuffed toggl"
version = "0.2.0" version = "2.0.0"
authors = ["Joakim Hulthe <joakim@hulthe.net>"] authors = ["Joakim Hulthe <joakim@hulthe.net>"]
license = "MPL-2.0" license = "MPL-2.0"
edition = "2018" edition = "2018"

View File

@ -0,0 +1,36 @@
pub mod v1_to_v2;
use crate::database::unversioned::global::schema_version;
use duplicate::duplicate;
use sled::Db;
pub fn migrate(db: &mut Db, from: schema_version::V, to: schema_version::V) -> Result<(), MigrationError> {
for current in from..to {
let next = current + 1;
println!("Will migrate from {} to {}", current, next);
match (current, next) {
(1, 2) => v1_to_v2::migrate(db)?,
_ => panic!("No valid migration from version {} to version {}", current, next),
}
}
Ok(())
}
#[derive(Debug)]
pub enum MigrationError {
Serde(bincode::Error),
Sled(sled::Error),
}
#[duplicate(
Variant Error;
[ Sled ] [ sled::Error ];
[ Serde ] [ bincode::Error ];
)]
impl From<Error> for MigrationError {
fn from(e: Error) -> MigrationError {
MigrationError::Variant(e)
}
}

View File

@ -0,0 +1,84 @@
use crate::database::v1;
use crate::database::v2;
use super::MigrationError;
use bincode::{serialize, deserialize};
use sled::Db;
use chrono::{DateTime, Utc};
/// Migrate from database version 1 to version 2
pub fn migrate(db: &mut Db) -> Result<(), MigrationError> {
{ // Migrate sessions
// Open old & new trees
let v1_past_sessions = db.open_tree(v1::trees::past_sessions::NAME)?;
let v2_sessions = db.open_tree(v2::trees::sessions::NAME)?;
// Iterate over old tree
for r in v1_past_sessions.iter() {
let (k, v) = r?;
// Deserialize old value
let v1::trees::past_sessions::V {
category,
started,
ended
} = deserialize(&v)?;
// Convert to new value
let v2_value = v2::trees::sessions::V {
category,
started: DateTime::<Utc>::from_utc(started, Utc).into(),
ended: DateTime::<Utc>::from_utc(ended, Utc).into(),
deleted: false,
};
// Insert into new tree
v2_sessions.insert(k, serialize(&v2_value)?)?;
}
// Remove old tree
v1_past_sessions.clear()?;
}
{ // Migrate categories
// Open the old tree, and a TMP tree since the old tree name hasn't changed
let categories = db.open_tree(v1::trees::categories::NAME)?;
let v2_categories_tmp = db.open_tree("TEMP")?;
// Iterate over old tree
for r in categories.iter() {
let (k, v) = r?;
// Deserialize old value
let v1::trees::categories::V {
name,
color,
started,
} = deserialize(&v)?;
// Convert to new value
let v2_value = v2::trees::categories::V {
name,
description: None,
color,
started: started.map(|ndt| DateTime::<Utc>::from_utc(ndt, Utc).into()),
parent: None,
deleted: false,
};
// Insert into temporary tree
v2_categories_tmp.insert(k, serialize(&v2_value)?)?;
}
// Copy data from temp-tree back into old tree
categories.clear()?;
for r in v2_categories_tmp.iter() {
let (k, v) = r?;
categories.insert(k ,v)?;
}
v2_categories_tmp.clear()?;
}
Ok(())
}

View File

@ -1,5 +1,8 @@
pub mod unversioned; pub mod unversioned;
pub mod v1; pub mod v1;
pub mod v2;
pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 1; pub mod migrations;
pub use self::v1 as latest;
pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2;
pub use v2 as latest;

View File

@ -1,3 +1,5 @@
#![allow(dead_code)] // old schema, not used anymore
pub(self) use chrono::NaiveDateTime; pub(self) use chrono::NaiveDateTime;
pub(self) use serde_derive::{Deserialize, Serialize}; pub(self) use serde_derive::{Deserialize, Serialize};
pub(self) use uuid::Uuid; pub(self) use uuid::Uuid;
@ -18,7 +20,7 @@ pub mod trees {
/// The name of the category /// The name of the category
pub name: String, pub name: String,
/// The color of the button in the rendered view /// The HTML color of the category in the rendered view
pub color: String, pub color: String,
/// If the session is not active, this will be None /// If the session is not active, this will be None

64
src/database/v2.rs Normal file
View File

@ -0,0 +1,64 @@
pub(self) use chrono::{DateTime, Local};
pub(self) use serde_derive::{Deserialize, Serialize};
pub(self) use uuid::Uuid;
/// Stuff in the default namespace
pub mod global {}
pub mod trees {
pub mod categories {
use super::super::*;
pub const NAME: &str = "CATEGORIES";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The name of the category
pub name: String,
/// The description of the category
pub description: Option<String>,
/// The HTML color of the category in the rendered view
pub color: String,
/// If the session is not active, this will be None
pub started: Option<DateTime<Local>>,
/// The parent category of this category
/// If none, the category has no parent
pub parent: Option<K>,
// FIXME: this field is currently not used
/// Whether the item has been "deleted", e.g. it shoudn't be shown in the view
pub deleted: bool,
}
}
pub mod sessions {
use super::super::*;
pub const NAME: &str = "SESSIONS";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The UUID of the category to which this session belongs
pub category: trees::categories::K,
/// The time when this session was started
pub started: DateTime<Local>,
/// The time when this session was ended
pub ended: DateTime<Local>,
// FIXME: this field is currently not used
/// Whether the item has been "deleted", e.g. it shoudn't be shown in the view
pub deleted: bool,
}
}
}

View File

@ -1,10 +1,10 @@
use chrono::NaiveDateTime; use chrono::{DateTime, Local};
use handlebars::handlebars_helper; use handlebars::handlebars_helper;
use rocket_contrib::templates::Engines; use rocket_contrib::templates::Engines;
pub fn register_helpers(engines: &mut Engines) { pub fn register_helpers(engines: &mut Engines) {
handlebars_helper!(pretty_datetime: |dt: str| { handlebars_helper!(pretty_datetime: |dt: str| {
let dt: NaiveDateTime = dt.parse().unwrap(); let dt: DateTime<Local> = dt.parse().unwrap();
format!("{}", dt.format("%Y-%m-%d %H:%M")) format!("{}", dt.format("%Y-%m-%d %H:%M"))
}); });
engines engines

View File

@ -5,7 +5,9 @@ mod routes;
mod status_json; mod status_json;
use bincode::{deserialize, serialize}; use bincode::{deserialize, serialize};
use database::{unversioned::global::schema_version, SCHEMA_VERSION}; use crate::database::SCHEMA_VERSION;
use crate::database::unversioned::global::schema_version;
use crate::database::migrations::migrate;
use dotenv::dotenv; use dotenv::dotenv;
use rocket_contrib::serve::StaticFiles; use rocket_contrib::serve::StaticFiles;
use rocket_contrib::templates::Template; use rocket_contrib::templates::Template;
@ -16,7 +18,7 @@ fn main() -> io::Result<()> {
let db_path = env::var("DB_PATH").expect("DB_PATH not set"); let db_path = env::var("DB_PATH").expect("DB_PATH not set");
let sled = sled::open(db_path)?; let mut sled = sled::open(db_path)?;
match sled.insert( match sled.insert(
serialize(schema_version::K).unwrap(), serialize(schema_version::K).unwrap(),
serialize(&SCHEMA_VERSION).unwrap(), serialize(&SCHEMA_VERSION).unwrap(),
@ -27,6 +29,9 @@ fn main() -> io::Result<()> {
"Schema version: {}, previously: {}", "Schema version: {}, previously: {}",
SCHEMA_VERSION, prev_schema_version SCHEMA_VERSION, prev_schema_version
); );
migrate(&mut sled, prev_schema_version, SCHEMA_VERSION)
.expect("Migration failed")
} }
None => { None => {
println!("Schema version: {}, previously: None", SCHEMA_VERSION); println!("Schema version: {}, previously: None", SCHEMA_VERSION);

View File

@ -1,9 +1,8 @@
use crate::database::latest::trees::{categories, past_sessions}; use crate::database::latest::trees::{categories, sessions};
use crate::routes::pages; use crate::routes::pages;
use crate::status_json::StatusJson; use crate::status_json::StatusJson;
use bincode::{deserialize, serialize}; use bincode::{deserialize, serialize};
use chrono::NaiveDateTime; use chrono::{NaiveDateTime, TimeZone, Duration, Local};
use chrono::{Duration, Utc};
use rocket::http::Status; use rocket::http::Status;
use rocket::request::{Form, FromForm}; use rocket::request::{Form, FromForm};
use rocket::response::Redirect; use rocket::response::Redirect;
@ -29,8 +28,11 @@ pub fn create_category(
serialize(&uuid::Uuid::new_v4())?, serialize(&uuid::Uuid::new_v4())?,
serialize(&categories::V { serialize(&categories::V {
name: category.name, name: category.name,
description: None,
color: category.color, color: category.color,
started: None, started: None,
parent: None,
deleted: false,
})?, })?,
)?; )?;
@ -68,8 +70,16 @@ pub fn bump_session(
let mut category: categories::V = deserialize(&data).unwrap(); let mut category: categories::V = deserialize(&data).unwrap();
match category.started.as_mut() { match category.started.as_mut() {
Some(started) => { Some(started) => {
*started -= duration; if let Some(new_started) = started.checked_sub_signed(duration) {
tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?; *started = new_started;
tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?;
} else {
return Ok(Err(StatusJson::new(
Status::BadRequest,
"Duration subtract resulted in overflow",
)))
}
Ok(Ok(Redirect::to(uri!(pages::index)))) Ok(Ok(Redirect::to(uri!(pages::index))))
} }
None => { None => {
@ -92,15 +102,15 @@ pub fn toggle_category_session(
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?); let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
let categories_tree = db.open_tree(categories::NAME)?; let categories_tree = db.open_tree(categories::NAME)?;
let past_sessions_tree = db.open_tree(past_sessions::NAME)?; let sessions_tree = db.open_tree(sessions::NAME)?;
Ok((&categories_tree, &past_sessions_tree).transaction( Ok((&categories_tree, &sessions_tree).transaction(
|(tx_categories, tx_past_sessions)| { |(tx_categories, tx_sessions)| {
match tx_categories.get(&category_uuid_s)? { match tx_categories.get(&category_uuid_s)? {
None => return Ok(Err(Status::NotFound)), None => return Ok(Err(Status::NotFound)),
Some(data) => { Some(data) => {
let mut category: categories::V = deserialize(&data).unwrap(); let mut category: categories::V = deserialize(&data).unwrap();
let now = Utc::now().naive_utc(); let now = Local::now();
match (set_active, category.started.take()) { match (set_active, category.started.take()) {
(false, Some(started)) => { (false, Some(started)) => {
@ -108,13 +118,14 @@ pub fn toggle_category_session(
let duration = now - started; let duration = now - started;
if duration > Duration::minutes(5) { if duration > Duration::minutes(5) {
let session_uuid = serialize(&uuid::Uuid::new_v4()).unwrap(); let session_uuid = serialize(&uuid::Uuid::new_v4()).unwrap();
let past_session = past_sessions::V { let session = sessions::V {
category: category_uuid.into_inner(), category: category_uuid.into_inner(),
started, started,
ended: now, ended: now,
deleted: category.deleted,
}; };
tx_past_sessions tx_sessions
.insert(session_uuid, serialize(&past_session).unwrap())?; .insert(session_uuid, serialize(&session).unwrap())?;
} }
} }
(true, None) => { (true, None) => {
@ -140,6 +151,7 @@ pub struct EditSession {
category: Uuid, category: Uuid,
started: String, started: String,
ended: String, ended: String,
deleted: bool,
} }
#[post("/session/<session_uuid>/edit", data = "<session>")] #[post("/session/<session_uuid>/edit", data = "<session>")]
@ -150,12 +162,11 @@ pub fn edit_session(
) -> Result<Redirect, StatusJson> { ) -> Result<Redirect, StatusJson> {
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
dbg!(&session); let session = sessions::V {
let session = past_sessions::V {
category: session.category.into_inner(), category: session.category.into_inner(),
started: NaiveDateTime::parse_from_str(&session.started, "%Y-%m-%d %H:%M")?, started: Local.from_local_datetime(&NaiveDateTime::parse_from_str(&session.started, "%Y-%m-%d %H:%M")?).unwrap(),
ended: NaiveDateTime::parse_from_str(&session.ended, "%Y-%m-%d %H:%M")?, ended: Local.from_local_datetime(&NaiveDateTime::parse_from_str(&session.ended, "%Y-%m-%d %H:%M")?).unwrap(),
deleted: session.deleted,
}; };
if session.started >= session.ended { if session.started >= session.ended {
@ -165,7 +176,7 @@ pub fn edit_session(
)); ));
} }
db.open_tree(past_sessions::NAME)? db.open_tree(sessions::NAME)?
.insert(session_uuid_s, serialize(&session)?)?; .insert(session_uuid_s, serialize(&session)?)?;
// FIXME: Uuid does not implement FromUriParam for some reason... File an issue? // FIXME: Uuid does not implement FromUriParam for some reason... File an issue?
@ -177,19 +188,19 @@ pub fn edit_session(
pub fn delete_session(session_uuid: Uuid, db: State<'_, sled::Db>) -> Result<Redirect, StatusJson> { pub fn delete_session(session_uuid: Uuid, db: State<'_, sled::Db>) -> Result<Redirect, StatusJson> {
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
let past_sessions_tree = db.open_tree(past_sessions::NAME)?; let sessions_tree = db.open_tree(sessions::NAME)?;
match past_sessions_tree.remove(session_uuid_s)? { match sessions_tree.remove(session_uuid_s)? {
Some(_) => Ok(Redirect::to(uri!(pages::history))), Some(_) => Ok(Redirect::to(uri!(pages::history))),
None => Err(Status::NotFound.into()), None => Err(Status::NotFound.into()),
} }
// TODO: mark as deleted instead of removing // TODO: mark as deleted instead of removing
// Ok(past_sessions_tree.transaction(|tx_past_sessions| { // Ok(sessions_tree.transaction(|tx_sessions| {
// match tx_past_sessions.get(&session_uuid_s)? { // match tx_sessions.get(&session_uuid_s)? {
// None => return Ok(Err(Status::NotFound)), // None => return Ok(Err(Status::NotFound)),
// Some(data) => { // Some(data) => {
// let mut past_session: past_sessions::V = deserialize(&data).unwrap(); // let mut session: sessions::V = deserialize(&data).unwrap();
// } // }
// } // }
// Ok(Ok(Redirect::to(uri!(pages::history)))) // Ok(Ok(Redirect::to(uri!(pages::history))))

View File

@ -1,4 +1,4 @@
use crate::database::latest::trees::{categories, past_sessions}; use crate::database::latest::trees::{categories, sessions};
use crate::status_json::StatusJson; use crate::status_json::StatusJson;
use bincode::deserialize; use bincode::deserialize;
use bincode::serialize; use bincode::serialize;
@ -35,14 +35,14 @@ pub fn index(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
pub fn session_edit(session_uuid: Uuid, db: State<'_, sled::Db>) -> Result<Template, StatusJson> { pub fn session_edit(session_uuid: Uuid, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct SessionPageContext { struct SessionPageContext {
session: past_sessions::V, session: sessions::V,
session_id: uuid::Uuid, session_id: uuid::Uuid,
} }
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
let past_sessions_tree = db.open_tree(past_sessions::NAME)?; let sessions_tree = db.open_tree(sessions::NAME)?;
match past_sessions_tree.get(session_uuid_s)? { match sessions_tree.get(session_uuid_s)? {
None => Err(Status::NotFound)?, None => Err(Status::NotFound)?,
Some(data) => { Some(data) => {
let context = SessionPageContext { let context = SessionPageContext {
@ -60,7 +60,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct HistoryEntryContext { struct HistoryEntryContext {
category: categories::V, category: categories::V,
session: past_sessions::V, session: sessions::V,
session_id: uuid::Uuid, session_id: uuid::Uuid,
duration: Duration, duration: Duration,
} }
@ -71,7 +71,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
} }
let categories_tree = db.open_tree(categories::NAME)?; let categories_tree = db.open_tree(categories::NAME)?;
let past_sessions_tree = db.open_tree(past_sessions::NAME)?; let sessions_tree = db.open_tree(sessions::NAME)?;
let categories: HashMap<categories::K, categories::V> = categories_tree let categories: HashMap<categories::K, categories::V> = categories_tree
.iter() .iter()
@ -80,7 +80,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
}) })
.collect::<Result<Result<_, _>, _>>()??; .collect::<Result<Result<_, _>, _>>()??;
let past_sessions: HashMap<past_sessions::K, past_sessions::V> = past_sessions_tree let sessions: HashMap<sessions::K, sessions::V> = sessions_tree
.iter() .iter()
.map(|result| { .map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
@ -88,7 +88,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
.collect::<Result<Result<_, _>, _>>()??; .collect::<Result<Result<_, _>, _>>()??;
let mut context = TemplateContext { let mut context = TemplateContext {
entries: past_sessions entries: sessions
.into_iter() .into_iter()
.map(|(session_id, session)| HistoryEntryContext { .map(|(session_id, session)| HistoryEntryContext {
duration: (session.ended - session.started).to_std().unwrap(), duration: (session.ended - session.started).to_std().unwrap(),

View File

@ -21,6 +21,7 @@
<br> <br>
<form action="/session/{{session_id}}/edit" method="post"> <form action="/session/{{session_id}}/edit" method="post">
<input type="hidden" id="category" name="category" value="{{session.category}}"> <input type="hidden" id="category" name="category" value="{{session.category}}">
<input type="hidden" id="deleted" name="deleted" value="{{session.deleted}}">
<span>Started:</span> <span>Started:</span>
<input type="text" id="started" name="started" value="{{pretty_datetime session.started}}"></input> <input type="text" id="started" name="started" value="{{pretty_datetime session.started}}"></input>
<br> <br>